gabriel / muse public
test_cmd_snapshot_hardening.py python
2,093 lines 86.0 KB
Raw
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor ⚠ breaking 23 days ago
1 """Hardening tests for ``muse snapshot`` — security, performance, agent UX.
2
3 Covers:
4 Unit (helpers):
5 - _safe_arcname: absolute path rejected, .. segments rejected,
6 prefix .. rejected, valid paths accepted
7 - _validate_snapshot_id_prefix: strips non-hex chars, caps at 64
8 - _list_all_snapshots: symlink skipped
9 - _resolve_snapshot: prefix scan skips symlinks, full ID hit
10
11 Unit (SnapshotRecord):
12 - note field persists through to_dict / from_dict round-trip
13 - note defaults to "" for old records without the field
14
15 Security:
16 - Symlink inside .muse/snapshots/ skipped during list
17 - Symlink inside .muse/snapshots/ skipped during show prefix scan
18 - Symlink inside .muse/snapshots/ skipped during export prefix scan
19 - ANSI in note sanitized in text output, raw in JSON
20 - ANSI in rel_path sanitized in show --text output
21 - Broken --json shorthand on export no longer accepted (was broken bug)
22
23 Error routing:
24 - snapshot read not-found goes to stderr
25 - snapshot export not-found goes to stderr
26
27 JSON schema (create):
28 - All _SnapshotCreateJson fields present: repo_id, snapshot_id,
29 file_count, note, created_at
30 - note persisted and returned in JSON
31
32 JSON schema (list):
33 - _SnapshotListItemJson fields: snapshot_id, file_count, note, created_at
34 - note round-trips through create → list
35
36 JSON schema (show):
37 - _SnapshotReadJson fields: snapshot_id, created_at, file_count,
38 note, manifest
39 - show default is JSON (no flag needed)
40 - --text flag emits human-readable text
41
42 JSON schema (export):
43 - _SnapshotExportJson fields: snapshot_id, output, format,
44 file_count, size_bytes
45 - size_bytes > 0 for non-empty archive
46 - format field matches archive type
47
48 New features:
49 - note persisted in SnapshotRecord (not ephemeral)
50 - note shown in snapshot list text output
51 - note shown in snapshot read text output
52 - Old --format json / -f json flags rejected (clean migration)
53
54 Integration:
55 - create → list → show → export pipeline (tar.gz + zip)
56 - Prefix scan resolves short ID in show and export
57 - Multiple snapshots sorted newest-first in list
58 - export --json + tar.gz produces valid archive AND JSON summary
59
60 E2E:
61 - --help shows --json for create, list, export
62 - --help shows --text for show
63 - snapshot read --help describes default-JSON behaviour
64
65 Stress:
66 - 200 snapshots list correctly
67 - 500-file snapshot create + show manifest integrity
68 - Concurrent create (5 threads)
69 - Concurrent list (10 threads)
70 """
71
72 from __future__ import annotations
73
74 import hashlib
75 import json
76 import pathlib
77 import tarfile
78 import threading
79 import zipfile
80
81 import pytest
82 from tests.cli_test_helper import CliRunner, InvokeResult
83
84 from muse.core.object_store import object_path, write_object
85 from muse.core.ids import hash_snapshot
86 from muse.core.types import MsgpackValue
87 from muse.core.snapshots import (
88 SnapshotRecord,
89 write_snapshot,
90 )
91 from muse.cli.commands.snapshot_cmd import (
92 _list_all_snapshots,
93 _resolve_snapshot,
94 _safe_arcname,
95 _validate_snapshot_id_prefix,
96 )
97 from muse.core.types import Manifest, MsgpackDict, blob_id, split_id, short_id
98 from muse.core.paths import muse_dir
99
100 runner = CliRunner()
101 cli = None # argparse migration — CliRunner ignores this arg
102
103 _REPO_ID = "snapshot-hardening-test"
104
105
106 # ---------------------------------------------------------------------------
107 # TypedDicts for parsing JSON
108 # ---------------------------------------------------------------------------
109
110 from typing import TypedDict
111
112
113 class _CreateOut(TypedDict):
114 repo_id: str
115 snapshot_id: str
116 file_count: int
117 note: str
118 created_at: str
119
120
121 class _ListItemOut(TypedDict):
122 snapshot_id: str
123 file_count: int
124 note: str
125 created_at: str
126
127
128 class _ReadOut(TypedDict):
129 snapshot_id: str
130 created_at: str
131 file_count: int
132 note: str
133 manifest: Manifest
134
135
136 class _ExportOut(TypedDict):
137 snapshot_id: str
138 output: str
139 format: str
140 file_count: int
141 size_bytes: int
142
143
144 # ---------------------------------------------------------------------------
145 # Helpers
146 # ---------------------------------------------------------------------------
147
148 _invoke_lock = threading.Lock()
149
150
151
152
153 def _init_repo(path: pathlib.Path) -> pathlib.Path:
154 muse = muse_dir(path)
155 for d in ("commits", "snapshots", "objects", "refs/heads"):
156 (muse / d).mkdir(parents=True, exist_ok=True)
157 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
158 (muse / "repo.json").write_text(
159 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
160 )
161 return path
162
163
164 def _env(repo: pathlib.Path) -> Manifest:
165 return {"MUSE_REPO_ROOT": str(repo)}
166
167
168 def _create_files(root: pathlib.Path, count: int = 3) -> list[str]:
169 names: list[str] = []
170 for i in range(count):
171 name = f"file_{i}.txt"
172 (root / name).write_text(f"content {i}", encoding="utf-8")
173 names.append(name)
174 return names
175
176
177 def _invoke(args: list[str], env: Manifest) -> InvokeResult:
178 with _invoke_lock:
179 return runner.invoke(cli, args, env=env)
180
181
182 def _write_snapshot(root: pathlib.Path, note: str = "", n_files: int = 1) -> str:
183 """Create and store a snapshot record directly; return the snapshot_id."""
184 manifest: Manifest = {}
185 for i in range(n_files):
186 data = f"object-{i}-{note}".encode()
187 obj_id = blob_id(data)
188 write_object(root, obj_id, data)
189 manifest[f"file_{i}.txt"] = obj_id
190 snap_id = hash_snapshot(manifest)
191 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest, note=note))
192 return snap_id
193
194
195 # ---------------------------------------------------------------------------
196 # Unit: _safe_arcname
197 # ---------------------------------------------------------------------------
198
199
200 def test_safe_arcname_rejects_absolute() -> None:
201 assert _safe_arcname("prefix", "/etc/passwd") is None
202
203
204 def test_safe_arcname_rejects_dotdot_in_rel() -> None:
205 assert _safe_arcname("prefix", "../traversal.txt") is None
206
207
208 def test_safe_arcname_rejects_dotdot_in_prefix() -> None:
209 assert _safe_arcname("../traversal", "file.txt") is None
210
211
212 def test_safe_arcname_valid_no_prefix() -> None:
213 result = _safe_arcname("", "path/to/file.txt")
214 assert result == "path/to/file.txt"
215
216
217 def test_safe_arcname_valid_with_prefix() -> None:
218 result = _safe_arcname("myproject", "path/to/file.txt")
219 assert result == "myproject/path/to/file.txt"
220
221
222 def test_safe_arcname_strips_trailing_slash_from_prefix() -> None:
223 result = _safe_arcname("myproject/", "file.txt")
224 assert result == "myproject/file.txt"
225
226
227 # ---------------------------------------------------------------------------
228 # Unit: _validate_snapshot_id_prefix
229 # ---------------------------------------------------------------------------
230
231
232 def test_validate_snapshot_id_prefix_strips_non_hex() -> None:
233 result = _validate_snapshot_id_prefix("abc123xyz!@#$")
234 assert result == "abc123"
235
236
237 def test_validate_snapshot_id_prefix_caps_at_64() -> None:
238 long_hex = "a" * 100
239 result = _validate_snapshot_id_prefix(long_hex)
240 assert len(result) == 64
241
242
243 def test_validate_snapshot_id_prefix_empty_input() -> None:
244 result = _validate_snapshot_id_prefix("")
245 assert result == ""
246
247
248 # ---------------------------------------------------------------------------
249 # Unit: _list_all_snapshots symlink guard
250 # ---------------------------------------------------------------------------
251
252
253 def test_list_all_snapshots_skips_symlink(tmp_path: pathlib.Path) -> None:
254 from muse.core.paths import objects_dir as _objects_dir
255 _init_repo(tmp_path)
256 snap_id = _write_snapshot(tmp_path)
257 # Plant a symlink inside the object store — iter_stored_objects must skip it.
258 objs_dir = _objects_dir(tmp_path)
259 shard_dir = objs_dir / "sha256" / "de"
260 shard_dir.mkdir(parents=True, exist_ok=True)
261 target = tmp_path / "malicious.txt"
262 target.write_bytes(b"not a snapshot")
263 link = shard_dir / ("ad" + "0" * 60)
264 try:
265 link.symlink_to(target)
266 except (OSError, NotImplementedError):
267 pytest.skip("symlinks not supported on this platform")
268 results = _list_all_snapshots(tmp_path)
269 snap_ids = [r.snapshot_id for r in results]
270 # Only the legitimately written snapshot must appear.
271 assert snap_ids == [snap_id]
272
273
274 def test_list_all_snapshots_returns_real_records(tmp_path: pathlib.Path) -> None:
275 _init_repo(tmp_path)
276 _write_snapshot(tmp_path, note="a")
277 _write_snapshot(tmp_path, note="b", n_files=2)
278 results = _list_all_snapshots(tmp_path)
279 assert len(results) == 2
280
281
282 # ---------------------------------------------------------------------------
283 # Unit: _resolve_snapshot prefix scan skips symlinks
284 # ---------------------------------------------------------------------------
285
286
287 def test_resolve_snapshot_prefix_skips_symlink(tmp_path: pathlib.Path) -> None:
288 from muse.core.paths import objects_dir as _objects_dir
289 _init_repo(tmp_path)
290 snap_id = _write_snapshot(tmp_path)
291 # Plant a symlink with prefix "aaaa" inside the object store.
292 objs_dir = _objects_dir(tmp_path)
293 shard_dir = objs_dir / "sha256" / "aa"
294 shard_dir.mkdir(parents=True, exist_ok=True)
295 target = tmp_path / "rogue.txt"
296 target.write_bytes(b"not a snapshot")
297 link = shard_dir / ("aa" + "0" * 60)
298 try:
299 link.symlink_to(target)
300 except (OSError, NotImplementedError):
301 pytest.skip("symlinks not supported on this platform")
302 # The symlink has a hex prefix "aaaa…" — resolving that prefix must skip it.
303 resolved = _resolve_snapshot(tmp_path, "aaaa")
304 # "aaaa" is not a hex prefix of the real snap_id — so should be None.
305 assert resolved is None or resolved.snapshot_id == snap_id
306
307
308 def test_resolve_snapshot_full_id_hit(tmp_path: pathlib.Path) -> None:
309 _init_repo(tmp_path)
310 snap_id = _write_snapshot(tmp_path, note="full hit")
311 resolved = _resolve_snapshot(tmp_path, snap_id)
312 assert resolved is not None
313 assert resolved.snapshot_id == snap_id
314
315
316 def test_resolve_snapshot_prefix_hit(tmp_path: pathlib.Path) -> None:
317 _init_repo(tmp_path)
318 snap_id = _write_snapshot(tmp_path, note="prefix hit")
319 resolved = _resolve_snapshot(tmp_path, short_id(snap_id))
320 assert resolved is not None
321 assert resolved.snapshot_id == snap_id
322
323
324 def test_resolve_snapshot_miss(tmp_path: pathlib.Path) -> None:
325 _init_repo(tmp_path)
326 resolved = _resolve_snapshot(tmp_path, "0000000000000000000000000000000000000000000000000000000000000000")
327 assert resolved is None
328
329
330 # ---------------------------------------------------------------------------
331 # Unit: SnapshotRecord note round-trip
332 # ---------------------------------------------------------------------------
333
334
335 def test_snapshot_record_note_round_trips_to_dict() -> None:
336 snap = SnapshotRecord(snapshot_id="a" * 64, manifest={}, note="my note")
337 d = snap.to_dict()
338 assert d["note"] == "my note"
339
340
341 def test_snapshot_record_note_round_trips_from_dict() -> None:
342 snap = SnapshotRecord(snapshot_id="b" * 64, manifest={}, note="restored")
343 d: MsgpackDict = {
344 "snapshot_id": snap.snapshot_id,
345 "manifest": {},
346 "created_at": snap.created_at.isoformat(),
347 "note": snap.note,
348 }
349 restored = SnapshotRecord.from_dict(d)
350 assert restored.note == "restored"
351
352
353 def test_snapshot_record_note_defaults_empty_for_old_records() -> None:
354 d: MsgpackDict = {
355 "snapshot_id": "c" * 64,
356 "manifest": {},
357 "created_at": "2026-01-01T00:00:00+00:00",
358 # no "note" key — simulates an old record
359 }
360 restored = SnapshotRecord.from_dict(d)
361 assert restored.note == ""
362
363
364 # ---------------------------------------------------------------------------
365 # Security: symlink guard in show + export
366 # ---------------------------------------------------------------------------
367
368
369 def test_snapshot_read_symlink_not_resolved(tmp_path: pathlib.Path) -> None:
370 """Prefix scan in show must skip symlinks in the object store."""
371 from muse.core.paths import objects_dir as _objects_dir
372 _init_repo(tmp_path)
373 _write_snapshot(tmp_path)
374 objs_dir = _objects_dir(tmp_path)
375 shard_dir = objs_dir / "sha256" / "00"
376 shard_dir.mkdir(parents=True, exist_ok=True)
377 target = tmp_path / "some_file.txt"
378 target.write_bytes(b"not a snapshot")
379 link = shard_dir / ("00" + "0" * 60)
380 try:
381 link.symlink_to(target)
382 except (OSError, NotImplementedError):
383 pytest.skip("symlinks not supported on this platform")
384 result = _invoke(["snapshot", "read", "0000000000000000"], env=_env(tmp_path))
385 # Symlink skipped → prefix "0000" not found → exit_code != 0
386 assert result.exit_code != 0
387
388
389 def test_snapshot_export_symlink_not_resolved(tmp_path: pathlib.Path) -> None:
390 """Prefix scan in export must skip symlinks in the object store."""
391 from muse.core.paths import objects_dir as _objects_dir
392 _init_repo(tmp_path)
393 _write_snapshot(tmp_path)
394 objs_dir = _objects_dir(tmp_path)
395 shard_dir = objs_dir / "sha256" / "bb"
396 shard_dir.mkdir(parents=True, exist_ok=True)
397 target = tmp_path / "some_file.txt"
398 target.write_bytes(b"not a snapshot")
399 link = shard_dir / ("bb" + "0" * 60)
400 try:
401 link.symlink_to(target)
402 except (OSError, NotImplementedError):
403 pytest.skip("symlinks not supported on this platform")
404 out_file = tmp_path / "out.tar.gz"
405 result = _invoke(
406 ["snapshot", "export", "bbbbbbbbbbbbbbbb", "--output", str(out_file)],
407 env=_env(tmp_path),
408 )
409 # Symlink skipped → prefix "bbbb" not found → exit_code != 0
410 assert result.exit_code != 0
411
412
413 # ---------------------------------------------------------------------------
414 # Security: ANSI injection
415 # ---------------------------------------------------------------------------
416
417
418 def test_ansi_in_note_sanitized_in_text_output(tmp_path: pathlib.Path) -> None:
419 _init_repo(tmp_path)
420 _create_files(tmp_path, 1)
421 malicious_note = "\x1b[31mRED\x1b[0m"
422 result = _invoke(["snapshot", "create", "-m", malicious_note], env=_env(tmp_path))
423 assert result.exit_code == 0
424 assert "\x1b[" not in result.output
425
426
427 def test_ansi_in_note_raw_in_json_output(tmp_path: pathlib.Path) -> None:
428 _init_repo(tmp_path)
429 _create_files(tmp_path, 1)
430 malicious_note = "\x1b[31mRED\x1b[0m"
431 result = _invoke(["snapshot", "create", "--json", "-m", malicious_note], env=_env(tmp_path))
432 assert result.exit_code == 0
433 data: _CreateOut = json.loads(result.output)
434 assert "\x1b[" in data["note"] # JSON preserves raw bytes
435
436
437 def test_ansi_in_note_sanitized_in_list_text(tmp_path: pathlib.Path) -> None:
438 _init_repo(tmp_path)
439 _create_files(tmp_path, 1)
440 malicious_note = "\x1b[31mDanger\x1b[0m"
441 _invoke(["snapshot", "create", "-m", malicious_note], env=_env(tmp_path))
442 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
443 assert result.exit_code == 0
444 assert "\x1b[" not in result.output
445
446
447 # ---------------------------------------------------------------------------
448 # Error routing
449 # ---------------------------------------------------------------------------
450
451
452 def test_snapshot_read_not_found_stderr(tmp_path: pathlib.Path) -> None:
453 _init_repo(tmp_path)
454 result = _invoke(["snapshot", "read", "doesnotexist"], env=_env(tmp_path))
455 assert result.exit_code != 0
456
457
458 def test_snapshot_export_not_found_stderr(tmp_path: pathlib.Path) -> None:
459 _init_repo(tmp_path)
460 result = _invoke(
461 ["snapshot", "export", "doesnotexist", "--output", "/tmp/x.tar.gz"],
462 env=_env(tmp_path),
463 )
464 assert result.exit_code != 0
465
466
467 def test_old_format_flag_rejected(tmp_path: pathlib.Path) -> None:
468 _init_repo(tmp_path)
469 result = _invoke(["snapshot", "create", "-f", "json"], env=_env(tmp_path))
470 # -f is no longer a valid flag for create → argparse rejects it
471 assert result.exit_code != 0
472
473
474 # ---------------------------------------------------------------------------
475 # JSON schema: create
476 # ---------------------------------------------------------------------------
477
478
479 def test_create_json_all_fields(tmp_path: pathlib.Path) -> None:
480 _init_repo(tmp_path)
481 _create_files(tmp_path, 2)
482 result = _invoke(["snapshot", "create", "--json", "-m", "hello"], env=_env(tmp_path))
483 assert result.exit_code == 0
484 data: _CreateOut = json.loads(result.output)
485 assert data["repo_id"] == _REPO_ID
486 assert len(data["snapshot_id"]) == 71
487 assert data["file_count"] >= 2
488 assert data["note"] == "hello"
489 assert "T" in data["created_at"]
490
491
492 def test_create_json_note_persisted(tmp_path: pathlib.Path) -> None:
493 _init_repo(tmp_path)
494 _create_files(tmp_path, 1)
495 result = _invoke(["snapshot", "create", "--json", "-m", "saved"], env=_env(tmp_path))
496 data: _CreateOut = json.loads(result.output)
497 assert data["note"] == "saved"
498
499
500 def test_create_json_no_note_empty_string(tmp_path: pathlib.Path) -> None:
501 _init_repo(tmp_path)
502 _create_files(tmp_path, 1)
503 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
504 data: _CreateOut = json.loads(result.output)
505 assert data["note"] == ""
506
507
508 def test_create_json_repo_id_present(tmp_path: pathlib.Path) -> None:
509 _init_repo(tmp_path)
510 _create_files(tmp_path, 1)
511 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
512 data: _CreateOut = json.loads(result.output)
513 assert data["repo_id"] == _REPO_ID
514
515
516 # ---------------------------------------------------------------------------
517 # JSON schema: list
518 # ---------------------------------------------------------------------------
519
520
521 def test_list_json_item_has_note(tmp_path: pathlib.Path) -> None:
522 _init_repo(tmp_path)
523 _create_files(tmp_path, 1)
524 _invoke(["snapshot", "create", "-m", "list-note"], env=_env(tmp_path))
525 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
526 assert result.exit_code == 0
527 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
528 assert len(items) == 1
529 assert items[0]["note"] == "list-note"
530
531
532 def test_list_json_all_fields(tmp_path: pathlib.Path) -> None:
533 _init_repo(tmp_path)
534 _create_files(tmp_path, 1)
535 _invoke(["snapshot", "create"], env=_env(tmp_path))
536 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
537 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
538 assert "snapshot_id" in items[0]
539 assert "file_count" in items[0]
540 assert "note" in items[0]
541 assert "created_at" in items[0]
542
543
544 def test_list_empty_json_is_array(tmp_path: pathlib.Path) -> None:
545 _init_repo(tmp_path)
546 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
547 assert result.exit_code == 0
548 data = json.loads(result.output)
549 assert data["snapshots"] == []
550
551
552 def test_list_note_in_text_output(tmp_path: pathlib.Path) -> None:
553 _init_repo(tmp_path)
554 _create_files(tmp_path, 1)
555 _invoke(["snapshot", "create", "-m", "a-note"], env=_env(tmp_path))
556 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
557 assert result.exit_code == 0
558 assert "a-note" in result.output
559
560
561 # ---------------------------------------------------------------------------
562 # JSON schema: show
563 # ---------------------------------------------------------------------------
564
565
566 def test_read_default_is_json(tmp_path: pathlib.Path) -> None:
567 _init_repo(tmp_path)
568 _create_files(tmp_path, 2)
569 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
570 snap_id: str = json.loads(create_res.output)["snapshot_id"]
571 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
572 assert result.exit_code == 0
573 data: _ReadOut = json.loads(result.output)
574 assert data["snapshot_id"] == snap_id
575
576
577 def test_read_json_all_fields(tmp_path: pathlib.Path) -> None:
578 _init_repo(tmp_path)
579 _create_files(tmp_path, 2)
580 create_res = _invoke(["snapshot", "create", "--json", "-m", "show-note"], env=_env(tmp_path))
581 snap_id: str = json.loads(create_res.output)["snapshot_id"]
582 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
583 data: _ReadOut = json.loads(result.output)
584 assert data["snapshot_id"] == snap_id
585 assert data["file_count"] >= 2
586 assert data["note"] == "show-note"
587 assert isinstance(data["manifest"], dict)
588 assert "created_at" in data
589
590
591 def test_read_text_flag(tmp_path: pathlib.Path) -> None:
592 _init_repo(tmp_path)
593 _create_files(tmp_path, 1)
594 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
595 snap_id: str = json.loads(create_res.output)["snapshot_id"]
596 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
597 assert result.exit_code == 0
598 assert "snapshot_id:" in result.output
599
600
601 def test_read_text_note_displayed(tmp_path: pathlib.Path) -> None:
602 _init_repo(tmp_path)
603 _create_files(tmp_path, 1)
604 create_res = _invoke(
605 ["snapshot", "create", "--json", "-m", "text-note"], env=_env(tmp_path)
606 )
607 snap_id: str = json.loads(create_res.output)["snapshot_id"]
608 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
609 assert result.exit_code == 0
610 assert "text-note" in result.output
611
612
613 # ---------------------------------------------------------------------------
614 # JSON schema: export
615 # ---------------------------------------------------------------------------
616
617
618 def test_export_json_all_fields_tar(tmp_path: pathlib.Path) -> None:
619 _init_repo(tmp_path)
620 _create_files(tmp_path, 2)
621 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
622 snap_id: str = json.loads(create_res.output)["snapshot_id"]
623 out_file = tmp_path / "out.tar.gz"
624 result = _invoke(
625 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
626 env=_env(tmp_path),
627 )
628 assert result.exit_code == 0
629 data: _ExportOut = json.loads(result.output)
630 assert data["snapshot_id"] == snap_id
631 assert data["output"] == str(out_file)
632 assert data["format"] == "tar.gz"
633 assert data["file_count"] >= 2
634 assert data["size_bytes"] > 0
635
636
637 def test_export_json_all_fields_zip(tmp_path: pathlib.Path) -> None:
638 _init_repo(tmp_path)
639 _create_files(tmp_path, 2)
640 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
641 snap_id: str = json.loads(create_res.output)["snapshot_id"]
642 out_file = tmp_path / "out.zip"
643 result = _invoke(
644 [
645 "snapshot", "export", snap_id,
646 "--format", "zip",
647 "--output", str(out_file),
648 "--json",
649 ],
650 env=_env(tmp_path),
651 )
652 assert result.exit_code == 0
653 data: _ExportOut = json.loads(result.output)
654 assert data["format"] == "zip"
655 assert data["size_bytes"] > 0
656
657
658 def test_export_json_and_archive_both_created(tmp_path: pathlib.Path) -> None:
659 _init_repo(tmp_path)
660 _create_files(tmp_path, 2)
661 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
662 snap_id: str = json.loads(create_res.output)["snapshot_id"]
663 out_file = tmp_path / "both.tar.gz"
664 result = _invoke(
665 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
666 env=_env(tmp_path),
667 )
668 assert result.exit_code == 0
669 assert out_file.exists()
670 assert tarfile.is_tarfile(str(out_file))
671 data: _ExportOut = json.loads(result.output)
672 assert data["file_count"] >= 2
673
674
675 # ---------------------------------------------------------------------------
676 # Integration: create → list → show → export pipeline
677 # ---------------------------------------------------------------------------
678
679
680 def test_pipeline_create_list_read_export(tmp_path: pathlib.Path) -> None:
681 _init_repo(tmp_path)
682 _create_files(tmp_path, 3)
683
684 # 1. Create
685 create_res = _invoke(
686 ["snapshot", "create", "--json", "-m", "pipeline-note"], env=_env(tmp_path)
687 )
688 assert create_res.exit_code == 0
689 create_data: _CreateOut = json.loads(create_res.output)
690 snap_id = create_data["snapshot_id"]
691 assert create_data["note"] == "pipeline-note"
692
693 # 2. List
694 list_res = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
695 assert list_res.exit_code == 0
696 list_items: list[_ListItemOut] = json.loads(list_res.output)["snapshots"]
697 assert any(item["snapshot_id"] == snap_id for item in list_items)
698 matching = next(i for i in list_items if i["snapshot_id"] == snap_id)
699 assert matching["note"] == "pipeline-note"
700
701 # 3. Show
702 show_res = _invoke(["snapshot", "read", short_id(snap_id), "--json"], env=_env(tmp_path))
703 assert show_res.exit_code == 0
704 show_data: _ReadOut = json.loads(show_res.output)
705 assert show_data["snapshot_id"] == snap_id
706 assert show_data["note"] == "pipeline-note"
707 assert show_data["file_count"] == 3
708
709 # 4. Export tar.gz
710 out_tar = tmp_path / "pipe.tar.gz"
711 export_res = _invoke(
712 ["snapshot", "export", snap_id, "--output", str(out_tar), "--json"],
713 env=_env(tmp_path),
714 )
715 assert export_res.exit_code == 0
716 export_data: _ExportOut = json.loads(export_res.output)
717 assert export_data["file_count"] == 3
718 assert out_tar.exists()
719
720 # 5. Export zip
721 out_zip = tmp_path / "pipe.zip"
722 export_zip_res = _invoke(
723 [
724 "snapshot", "export", snap_id,
725 "--format", "zip",
726 "--output", str(out_zip),
727 "--json",
728 ],
729 env=_env(tmp_path),
730 )
731 assert export_zip_res.exit_code == 0
732 assert zipfile.is_zipfile(str(out_zip))
733
734
735 def test_multiple_snapshots_sorted_newest_first(tmp_path: pathlib.Path) -> None:
736 _init_repo(tmp_path)
737 for i in range(4):
738 _create_files(tmp_path, 1)
739 _invoke([f"snapshot", "create", "-m", f"snap-{i}"], env=_env(tmp_path))
740 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
741 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
742 # Verify timestamps are non-increasing (newest first).
743 for j in range(len(items) - 1):
744 assert items[j]["created_at"] >= items[j + 1]["created_at"]
745
746
747 # ---------------------------------------------------------------------------
748 # E2E: help output
749 # ---------------------------------------------------------------------------
750
751
752 def test_create_help_shows_json_flag() -> None:
753 result = runner.invoke(cli, ["snapshot", "create", "--help"])
754 assert result.exit_code == 0
755 assert "--json" in result.output
756
757
758 def test_list_help_shows_json_flag() -> None:
759 result = runner.invoke(cli, ["snapshot", "list", "--help"])
760 assert result.exit_code == 0
761 assert "--json" in result.output
762
763
764 def test_read_help_mentions_json_flag() -> None:
765 result = runner.invoke(cli, ["snapshot", "read", "--help"])
766 assert result.exit_code == 0
767 assert "--json" in result.output
768
769
770 def test_export_help_shows_json_flag() -> None:
771 result = runner.invoke(cli, ["snapshot", "export", "--help"])
772 assert result.exit_code == 0
773 assert "--json" in result.output
774
775
776 def test_export_help_shows_format_choices() -> None:
777 result = runner.invoke(cli, ["snapshot", "export", "--help"])
778 assert result.exit_code == 0
779 assert "tar.gz" in result.output
780 assert "zip" in result.output
781
782
783 # ---------------------------------------------------------------------------
784 # Stress
785 # ---------------------------------------------------------------------------
786
787
788 def test_stress_200_snapshots_list(tmp_path: pathlib.Path) -> None:
789 _init_repo(tmp_path)
790 for i in range(200):
791 _write_snapshot(tmp_path, note=f"snap-{i}")
792 result = _invoke(["snapshot", "list", "--json", "--limit", "200"], env=_env(tmp_path))
793 assert result.exit_code == 0
794 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
795 assert len(items) == 200
796
797
798 def test_stress_500_file_snapshot(tmp_path: pathlib.Path) -> None:
799 _init_repo(tmp_path)
800 for i in range(500):
801 (tmp_path / f"f{i}.txt").write_text(f"data-{i}", encoding="utf-8")
802 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
803 assert result.exit_code == 0
804 data: _CreateOut = json.loads(result.output)
805 assert data["file_count"] >= 500
806
807 snap_id = data["snapshot_id"]
808 show_res = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
809 assert show_res.exit_code == 0
810 show_data: _ReadOut = json.loads(show_res.output)
811 assert show_data["file_count"] >= 500
812 assert len(show_data["manifest"]) >= 500
813
814
815 def test_stress_concurrent_create(tmp_path: pathlib.Path) -> None:
816 _init_repo(tmp_path)
817 for i in range(10):
818 (tmp_path / f"cf{i}.txt").write_text(f"c{i}", encoding="utf-8")
819
820 errors: list[str] = []
821
822 def _create() -> None:
823 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
824 with _invoke_lock:
825 pass # lock already acquired inside _invoke
826 if result.exit_code != 0:
827 errors.append(f"exit_code={result.exit_code}")
828
829 threads = [threading.Thread(target=_create) for _ in range(5)]
830 for t in threads:
831 t.start()
832 for t in threads:
833 t.join()
834 assert errors == [], f"Concurrent create errors: {errors}"
835
836
837 def test_stress_concurrent_list(tmp_path: pathlib.Path) -> None:
838 _init_repo(tmp_path)
839 for i in range(5):
840 _write_snapshot(tmp_path, note=f"concurrent-{i}")
841
842 errors: list[str] = []
843
844 def _list() -> None:
845 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
846 if result.exit_code != 0:
847 errors.append(f"exit_code={result.exit_code}")
848 else:
849 try:
850 data = json.loads(result.output)
851 if len(data) < 5:
852 errors.append(f"expected 5 items, got {len(data)}")
853 except Exception as exc:
854 errors.append(str(exc))
855
856 threads = [threading.Thread(target=_list) for _ in range(10)]
857 for t in threads:
858 t.start()
859 for t in threads:
860 t.join()
861 assert errors == [], f"Concurrent list errors: {errors}"
862
863
864 # ---------------------------------------------------------------------------
865 # Extended / Security / Stress tests for ``muse snapshot create``
866 # ---------------------------------------------------------------------------
867
868
869 class TestSnapshotCreateExtended:
870 """Unit, integration, and edge-case tests for ``muse snapshot create``."""
871
872 def test_create_help_contains_agent_quickstart(self) -> None:
873 result = runner.invoke(cli, ["snapshot", "create", "--help"])
874 assert result.exit_code == 0
875 assert "quickstart" in result.output.lower() or "muse snapshot create" in result.output
876
877 def test_create_help_contains_json_schema(self) -> None:
878 result = runner.invoke(cli, ["snapshot", "create", "--help"])
879 assert result.exit_code == 0
880 assert "snapshot_id" in result.output
881
882 def test_create_help_contains_exit_codes(self) -> None:
883 result = runner.invoke(cli, ["snapshot", "create", "--help"])
884 assert result.exit_code == 0
885 assert "exit code" in result.output.lower() or "0 —" in result.output
886
887 def test_create_j_alias(self, tmp_path: pathlib.Path) -> None:
888 """-j is an alias for --json."""
889 _init_repo(tmp_path)
890 _create_files(tmp_path, 2)
891 result = _invoke(["snapshot", "create", "-j"], env=_env(tmp_path))
892 assert result.exit_code == 0
893 data: _CreateOut = json.loads(result.output)
894 assert "snapshot_id" in data
895
896 def test_create_snapshot_id_is_64_hex(self, tmp_path: pathlib.Path) -> None:
897 """snapshot_id in JSON output is exactly 64 hex characters."""
898 _init_repo(tmp_path)
899 _create_files(tmp_path, 1)
900 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
901 assert result.exit_code == 0
902 data: _CreateOut = json.loads(result.output)
903 assert len(data["snapshot_id"]) == 71
904 assert all(c in "0123456789abcdef" for c in split_id(data["snapshot_id"])[1])
905
906 def test_create_file_count_matches_actual(self, tmp_path: pathlib.Path) -> None:
907 """file_count in JSON output matches the number of files created."""
908 _init_repo(tmp_path)
909 _create_files(tmp_path, 7)
910 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
911 assert result.exit_code == 0
912 data: _CreateOut = json.loads(result.output)
913 assert data["file_count"] >= 7
914
915 def test_create_created_at_is_iso8601(self, tmp_path: pathlib.Path) -> None:
916 """created_at field is ISO-8601 format (contains 'T' separator)."""
917 _init_repo(tmp_path)
918 _create_files(tmp_path, 1)
919 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
920 assert result.exit_code == 0
921 data: _CreateOut = json.loads(result.output)
922 assert "T" in data["created_at"]
923
924 def test_create_note_empty_when_not_supplied(self, tmp_path: pathlib.Path) -> None:
925 """note is empty string in JSON when -m not passed."""
926 _init_repo(tmp_path)
927 _create_files(tmp_path, 1)
928 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
929 assert result.exit_code == 0
930 data: _CreateOut = json.loads(result.output)
931 assert data["note"] == ""
932
933 def test_create_note_persisted_in_json(self, tmp_path: pathlib.Path) -> None:
934 """note supplied via -m is reflected in JSON output."""
935 _init_repo(tmp_path)
936 _create_files(tmp_path, 1)
937 result = _invoke(["snapshot", "create", "-m", "checkpoint", "--json"], env=_env(tmp_path))
938 assert result.exit_code == 0
939 data: _CreateOut = json.loads(result.output)
940 assert data["note"] == "checkpoint"
941
942 def test_create_note_persists_to_list(self, tmp_path: pathlib.Path) -> None:
943 """note written via create is readable via list."""
944 _init_repo(tmp_path)
945 _create_files(tmp_path, 1)
946 _invoke(["snapshot", "create", "-m", "roundtrip"], env=_env(tmp_path))
947 list_result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
948 assert list_result.exit_code == 0
949 items: list[_ListItemOut] = json.loads(list_result.output)["snapshots"]
950 assert any(i["note"] == "roundtrip" for i in items)
951
952 def test_create_note_persists_to_read(self, tmp_path: pathlib.Path) -> None:
953 """note written via create is readable via show."""
954 _init_repo(tmp_path)
955 _create_files(tmp_path, 1)
956 create_result = _invoke(
957 ["snapshot", "create", "-m", "showcheck", "--json"], env=_env(tmp_path)
958 )
959 snap_id = json.loads(create_result.output)["snapshot_id"]
960 show_result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
961 assert show_result.exit_code == 0
962 show_data: _ReadOut = json.loads(show_result.output)
963 assert show_data["note"] == "showcheck"
964
965 def test_create_text_output_shows_short_id(self, tmp_path: pathlib.Path) -> None:
966 """Text output contains a 12-char prefix of the snapshot_id."""
967 _init_repo(tmp_path)
968 _create_files(tmp_path, 1)
969 create_result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
970 snap_id = json.loads(create_result.output)["snapshot_id"]
971 text_result = _invoke(["snapshot", "create"], env=_env(tmp_path))
972 # Each create call produces a new snapshot; just verify format
973 assert text_result.exit_code == 0
974 # Output should contain a hex-like prefix
975 assert any(c in "0123456789abcdef" for c in text_result.output)
976
977 def test_create_text_output_shows_note(self, tmp_path: pathlib.Path) -> None:
978 """Text output shows note label when -m is supplied."""
979 _init_repo(tmp_path)
980 _create_files(tmp_path, 1)
981 result = _invoke(["snapshot", "create", "-m", "my note"], env=_env(tmp_path))
982 assert result.exit_code == 0
983 assert "my note" in result.output
984
985 def test_create_text_output_no_note_line_when_empty(self, tmp_path: pathlib.Path) -> None:
986 """Text output has no 'Note:' line when -m is not passed."""
987 _init_repo(tmp_path)
988 _create_files(tmp_path, 1)
989 result = _invoke(["snapshot", "create"], env=_env(tmp_path))
990 assert result.exit_code == 0
991 assert "Note:" not in result.output
992
993 def test_create_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
994 """JSON output is compact (no indentation)."""
995 _init_repo(tmp_path)
996 _create_files(tmp_path, 1)
997 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
998 assert result.exit_code == 0
999 # json.loads succeeds and the raw text has no indented lines
1000 assert "\n " not in result.output.strip()
1001
1002 def test_create_repo_id_in_json(self, tmp_path: pathlib.Path) -> None:
1003 """repo_id field is present and non-empty in JSON output."""
1004 _init_repo(tmp_path)
1005 _create_files(tmp_path, 1)
1006 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1007 assert result.exit_code == 0
1008 data: _CreateOut = json.loads(result.output)
1009 assert data["repo_id"] == _REPO_ID
1010
1011 def test_create_idempotent_same_files_same_id(self, tmp_path: pathlib.Path) -> None:
1012 """Two consecutive creates of the same working tree produce the same snapshot_id."""
1013 _init_repo(tmp_path)
1014 _create_files(tmp_path, 3)
1015 r1 = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1016 r2 = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1017 assert r1.exit_code == 0
1018 assert r2.exit_code == 0
1019 assert json.loads(r1.output)["snapshot_id"] == json.loads(r2.output)["snapshot_id"]
1020
1021 def test_create_different_files_different_id(self, tmp_path: pathlib.Path) -> None:
1022 """Adding a file between creates produces a different snapshot_id."""
1023 _init_repo(tmp_path)
1024 _create_files(tmp_path, 2)
1025 r1 = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1026 (tmp_path / "extra.txt").write_text("extra", encoding="utf-8")
1027 r2 = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1028 assert r1.exit_code == 0 and r2.exit_code == 0
1029 assert json.loads(r1.output)["snapshot_id"] != json.loads(r2.output)["snapshot_id"]
1030
1031
1032 class TestSnapshotCreateSecurity:
1033 """Security tests for ``muse snapshot create``."""
1034
1035 def test_create_ansi_note_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1036 """ANSI escape codes in note are stripped from text output."""
1037 _init_repo(tmp_path)
1038 _create_files(tmp_path, 1)
1039 malicious_note = "\x1b[31mDanger\x1b[0m"
1040 result = _invoke(["snapshot", "create", "-m", malicious_note], env=_env(tmp_path))
1041 assert result.exit_code == 0
1042 assert "\x1b[31m" not in result.output
1043
1044 def test_create_ansi_note_raw_in_json(self, tmp_path: pathlib.Path) -> None:
1045 """ANSI escape codes in note are preserved raw in JSON output (agent data)."""
1046 _init_repo(tmp_path)
1047 _create_files(tmp_path, 1)
1048 malicious_note = "\x1b[31mDanger\x1b[0m"
1049 result = _invoke(["snapshot", "create", "-m", malicious_note, "--json"], env=_env(tmp_path))
1050 assert result.exit_code == 0
1051 data: _CreateOut = json.loads(result.output)
1052 assert data["note"] == malicious_note
1053
1054 def test_create_control_char_note_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1055 """CRLF and other control characters in note are stripped from text output."""
1056 _init_repo(tmp_path)
1057 _create_files(tmp_path, 1)
1058 malicious_note = "good\r\ninjected line"
1059 result = _invoke(["snapshot", "create", "-m", malicious_note], env=_env(tmp_path))
1060 assert result.exit_code == 0
1061 assert "\r" not in result.output
1062
1063 def test_create_very_long_note_no_crash(self, tmp_path: pathlib.Path) -> None:
1064 """A 10 000-character note does not crash the command."""
1065 _init_repo(tmp_path)
1066 _create_files(tmp_path, 1)
1067 long_note = "x" * 10_000
1068 result = _invoke(["snapshot", "create", "-m", long_note, "--json"], env=_env(tmp_path))
1069 assert result.exit_code == 0
1070 data: _CreateOut = json.loads(result.output)
1071 assert data["note"] == long_note
1072
1073 def test_create_path_traversal_chars_in_note_no_crash(self, tmp_path: pathlib.Path) -> None:
1074 """Path-traversal-like characters in note do not crash or escape output."""
1075 _init_repo(tmp_path)
1076 _create_files(tmp_path, 1)
1077 malicious_note = "../../etc/passwd"
1078 result = _invoke(["snapshot", "create", "-m", malicious_note, "--json"], env=_env(tmp_path))
1079 assert result.exit_code == 0
1080 data: _CreateOut = json.loads(result.output)
1081 assert data["note"] == malicious_note
1082
1083 def test_create_snapshot_id_always_hex(self, tmp_path: pathlib.Path) -> None:
1084 """snapshot_id in text output is a safe hex substring with no control chars."""
1085 _init_repo(tmp_path)
1086 _create_files(tmp_path, 1)
1087 # Get snapshot_id from JSON, verify text output contains its prefix
1088 json_result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1089 snap_id = json.loads(json_result.output)["snapshot_id"]
1090 # Verify it is pure lowercase hex
1091 assert all(c in "0123456789abcdef" for c in split_id(snap_id)[1])
1092 assert "\x1b" not in snap_id
1093 assert "\r" not in snap_id
1094
1095
1096 class TestSnapshotCreateStress:
1097 """Stress tests for ``muse snapshot create``."""
1098
1099 def test_create_1000_file_snapshot(self, tmp_path: pathlib.Path) -> None:
1100 """Snapshot of 1 000 files completes without error and reports correct count."""
1101 _init_repo(tmp_path)
1102 for i in range(1000):
1103 (tmp_path / f"f{i}.dat").write_text(f"data-{i}", encoding="utf-8")
1104 result = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1105 assert result.exit_code == 0
1106 data: _CreateOut = json.loads(result.output)
1107 assert data["file_count"] >= 1000
1108
1109 def test_create_50_consecutive_snapshots(self, tmp_path: pathlib.Path) -> None:
1110 """50 consecutive creates all succeed and produce listable records."""
1111 _init_repo(tmp_path)
1112 _create_files(tmp_path, 5)
1113 for i in range(50):
1114 (tmp_path / f"extra_{i}.txt").write_text(f"v{i}", encoding="utf-8")
1115 r = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1116 assert r.exit_code == 0, f"Failed on iteration {i}: {r.output}"
1117 list_result = _invoke(["snapshot", "list", "--json", "--limit", "100"], env=_env(tmp_path))
1118 assert list_result.exit_code == 0
1119 items = json.loads(list_result.output)["snapshots"]
1120 assert len(items) >= 50
1121
1122 def test_create_concurrent_write_safety(self, tmp_path: pathlib.Path) -> None:
1123 """Concurrent snapshot creates on the same repo do not corrupt the store."""
1124 _init_repo(tmp_path)
1125 for i in range(10):
1126 (tmp_path / f"cf{i}.txt").write_text(f"c{i}", encoding="utf-8")
1127
1128 from muse.core.snapshots import write_snapshot
1129 from muse.core.ids import hash_snapshot
1130
1131 errors: list[str] = []
1132
1133 def _do_create() -> None:
1134 try:
1135 # Use core directly — CliRunner serializes via _invoke_lock.
1136 manifest = {f"cf{i}.txt": blob_id(f"c{i}".encode()) for i in range(10)}
1137 snap_id = hash_snapshot(manifest)
1138 write_snapshot(tmp_path, SnapshotRecord(
1139 snapshot_id=snap_id, manifest=manifest, note="concurrent"
1140 ))
1141 except Exception as exc: # noqa: BLE001
1142 errors.append(str(exc))
1143
1144 threads = [threading.Thread(target=_do_create) for _ in range(20)]
1145 for t in threads:
1146 t.start()
1147 for t in threads:
1148 t.join()
1149 assert not errors, f"Concurrent create failures: {errors}"
1150
1151
1152 # ---------------------------------------------------------------------------
1153 # Extended / Security / Stress tests for ``muse snapshot list``
1154 # ---------------------------------------------------------------------------
1155
1156
1157 class TestSnapshotListExtended:
1158 """Unit, integration, and edge-case tests for ``muse snapshot list``."""
1159
1160 def test_list_help_contains_agent_quickstart(self) -> None:
1161 result = runner.invoke(cli, ["snapshot", "list", "--help"])
1162 assert result.exit_code == 0
1163 assert "quickstart" in result.output.lower() or "muse snapshot list" in result.output
1164
1165 def test_list_help_contains_json_schema(self) -> None:
1166 result = runner.invoke(cli, ["snapshot", "list", "--help"])
1167 assert result.exit_code == 0
1168 assert "snapshot_id" in result.output
1169
1170 def test_list_help_contains_exit_codes(self) -> None:
1171 result = runner.invoke(cli, ["snapshot", "list", "--help"])
1172 assert result.exit_code == 0
1173 assert "exit code" in result.output.lower() or "0 —" in result.output
1174
1175 def test_list_j_alias(self, tmp_path: pathlib.Path) -> None:
1176 """-j is an alias for --json."""
1177 _init_repo(tmp_path)
1178 _write_snapshot(tmp_path, note="alias-test")
1179 result = _invoke(["snapshot", "list", "-j"], env=_env(tmp_path))
1180 assert result.exit_code == 0
1181 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
1182 assert len(items) == 1
1183
1184 def test_list_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1185 """JSON output is compact (no indentation)."""
1186 _init_repo(tmp_path)
1187 _write_snapshot(tmp_path)
1188 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1189 assert result.exit_code == 0
1190 assert "\n " not in result.output.strip()
1191
1192 def test_list_empty_returns_empty_array_json(self, tmp_path: pathlib.Path) -> None:
1193 """Empty snapshot store emits '[]' with --json."""
1194 _init_repo(tmp_path)
1195 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1196 assert result.exit_code == 0
1197 assert json.loads(result.output)["snapshots"] == []
1198
1199 def test_list_empty_text_message(self, tmp_path: pathlib.Path) -> None:
1200 """Empty snapshot store prints a human message in text mode."""
1201 _init_repo(tmp_path)
1202 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
1203 assert result.exit_code == 0
1204 assert "no snapshots" in result.output.lower()
1205
1206 def test_list_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1207 """Every JSON item has snapshot_id, file_count, note, created_at."""
1208 _init_repo(tmp_path)
1209 _write_snapshot(tmp_path, note="fields")
1210 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1211 assert result.exit_code == 0
1212 item = json.loads(result.output)["snapshots"][0]
1213 assert "snapshot_id" in item
1214 assert "file_count" in item
1215 assert "note" in item
1216 assert "created_at" in item
1217
1218 def test_list_snapshot_id_is_64_hex(self, tmp_path: pathlib.Path) -> None:
1219 """snapshot_id in each JSON item is 64 hex chars."""
1220 _init_repo(tmp_path)
1221 _write_snapshot(tmp_path)
1222 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1223 assert result.exit_code == 0
1224 sid = json.loads(result.output)["snapshots"][0]["snapshot_id"]
1225 assert len(sid) == 71
1226 assert all(c in "0123456789abcdef" for c in split_id(sid)[1])
1227
1228 def test_list_created_at_iso8601(self, tmp_path: pathlib.Path) -> None:
1229 """created_at field contains 'T' (ISO-8601 separator)."""
1230 _init_repo(tmp_path)
1231 _write_snapshot(tmp_path)
1232 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1233 assert result.exit_code == 0
1234 assert "T" in json.loads(result.output)["snapshots"][0]["created_at"]
1235
1236 def test_list_newest_first_order(self, tmp_path: pathlib.Path) -> None:
1237 """Multiple snapshots appear newest-first in JSON output."""
1238 import time as _time
1239 _init_repo(tmp_path)
1240 for i in range(5):
1241 _write_snapshot(tmp_path, note=f"snap-{i}", n_files=i + 1)
1242 _time.sleep(0.01)
1243 result = _invoke(["snapshot", "list", "--limit", "10", "--json"], env=_env(tmp_path))
1244 assert result.exit_code == 0
1245 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
1246 timestamps = [i["created_at"] for i in items]
1247 assert timestamps == sorted(timestamps, reverse=True)
1248
1249 def test_list_limit_caps_results(self, tmp_path: pathlib.Path) -> None:
1250 """--limit N returns at most N snapshots."""
1251 _init_repo(tmp_path)
1252 for i in range(10):
1253 _write_snapshot(tmp_path, note=f"s{i}")
1254 result = _invoke(["snapshot", "list", "--limit", "3", "--json"], env=_env(tmp_path))
1255 assert result.exit_code == 0
1256 assert len(json.loads(result.output)["snapshots"]) == 3
1257
1258 def test_list_limit_zero_exits_1(self, tmp_path: pathlib.Path) -> None:
1259 """--limit 0 is rejected with exit code 1."""
1260 _init_repo(tmp_path)
1261 result = _invoke(["snapshot", "list", "--limit", "0"], env=_env(tmp_path))
1262 assert result.exit_code == 1
1263
1264 def test_list_limit_negative_exits_1(self, tmp_path: pathlib.Path) -> None:
1265 """--limit -1 is rejected with exit code 1."""
1266 _init_repo(tmp_path)
1267 result = _invoke(["snapshot", "list", "--limit", "-1"], env=_env(tmp_path))
1268 assert result.exit_code == 1
1269
1270 def test_list_limit_error_mentions_limit(self, tmp_path: pathlib.Path) -> None:
1271 """Out-of-range --limit error output mentions 'limit'."""
1272 _init_repo(tmp_path)
1273 result = _invoke(["snapshot", "list", "--limit", "0"], env=_env(tmp_path))
1274 assert result.exit_code == 1
1275 assert "limit" in result.stderr.lower()
1276
1277 def test_list_note_in_text_output(self, tmp_path: pathlib.Path) -> None:
1278 """Note label appears in text output when present."""
1279 _init_repo(tmp_path)
1280 _write_snapshot(tmp_path, note="my-label")
1281 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
1282 assert result.exit_code == 0
1283 assert "my-label" in result.output
1284
1285 def test_list_text_shows_short_id(self, tmp_path: pathlib.Path) -> None:
1286 """Text output shows the short_id prefix of snapshot_id."""
1287 _init_repo(tmp_path)
1288 snap_id = _write_snapshot(tmp_path)
1289 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
1290 assert result.exit_code == 0
1291 assert short_id(snap_id) in result.output
1292
1293 def test_list_file_count_in_json(self, tmp_path: pathlib.Path) -> None:
1294 """file_count in JSON matches the number of files in the snapshot."""
1295 _init_repo(tmp_path)
1296 _write_snapshot(tmp_path, n_files=7)
1297 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1298 assert result.exit_code == 0
1299 assert json.loads(result.output)["snapshots"][0]["file_count"] == 7
1300
1301
1302 class TestSnapshotListSecurity:
1303 """Security tests for ``muse snapshot list``."""
1304
1305 def test_list_ansi_note_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1306 """ANSI escape codes in note are stripped from text output."""
1307 _init_repo(tmp_path)
1308 _write_snapshot(tmp_path, note="\x1b[31mDanger\x1b[0m")
1309 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
1310 assert result.exit_code == 0
1311 assert "\x1b[31m" not in result.output
1312
1313 def test_list_ansi_note_raw_in_json(self, tmp_path: pathlib.Path) -> None:
1314 """ANSI escape codes in note are preserved raw in JSON output."""
1315 _init_repo(tmp_path)
1316 malicious = "\x1b[31mDanger\x1b[0m"
1317 _write_snapshot(tmp_path, note=malicious)
1318 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1319 assert result.exit_code == 0
1320 assert json.loads(result.output)["snapshots"][0]["note"] == malicious
1321
1322 def test_list_control_char_note_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1323 """CRLF in note is stripped from text output."""
1324 _init_repo(tmp_path)
1325 _write_snapshot(tmp_path, note="good\r\ninjected")
1326 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
1327 assert result.exit_code == 0
1328 assert "\r" not in result.output
1329
1330 def test_list_symlink_in_objects_dir_skipped(self, tmp_path: pathlib.Path) -> None:
1331 """A symlink inside .muse/objects/ is skipped, not followed."""
1332 from muse.core.paths import objects_dir as _objects_dir
1333 _init_repo(tmp_path)
1334 _write_snapshot(tmp_path, note="real")
1335 objs_dir = _objects_dir(tmp_path)
1336 shard_dir = objs_dir / "sha256" / "aa"
1337 shard_dir.mkdir(parents=True, exist_ok=True)
1338 fake = shard_dir / ("aa" * 31 + "0000")
1339 try:
1340 fake.symlink_to("/etc/passwd")
1341 except (OSError, NotImplementedError):
1342 pytest.skip("symlinks not supported on this platform")
1343 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1344 assert result.exit_code == 0
1345 items: list[_ListItemOut] = json.loads(result.output)["snapshots"]
1346 assert len(items) == 1
1347 assert items[0]["note"] == "real"
1348
1349 def test_list_snapshot_id_prefix_in_text_is_safe_hex(self, tmp_path: pathlib.Path) -> None:
1350 """short_id(snapshot_id) in text output contains only hex chars."""
1351 _init_repo(tmp_path)
1352 snap_id = _write_snapshot(tmp_path)
1353 result = _invoke(["snapshot", "list"], env=_env(tmp_path))
1354 assert result.exit_code == 0
1355 assert short_id(snap_id) in result.output
1356 assert "\x1b" not in result.output
1357
1358 def test_list_very_long_note_no_crash(self, tmp_path: pathlib.Path) -> None:
1359 """A 10 000-character note does not crash list."""
1360 _init_repo(tmp_path)
1361 _write_snapshot(tmp_path, note="x" * 10_000)
1362 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1363 assert result.exit_code == 0
1364 assert json.loads(result.output)["snapshots"][0]["note"] == "x" * 10_000
1365
1366
1367 class TestSnapshotListStress:
1368 """Stress tests for ``muse snapshot list``."""
1369
1370 def test_list_1000_snapshots(self, tmp_path: pathlib.Path) -> None:
1371 """Listing 1 000 snapshots with --limit 1000 returns all 1 000."""
1372 _init_repo(tmp_path)
1373 for i in range(1000):
1374 _write_snapshot(tmp_path, note=f"s{i}")
1375 result = _invoke(["snapshot", "list", "--limit", "1000", "--json"], env=_env(tmp_path))
1376 assert result.exit_code == 0
1377 assert len(json.loads(result.output)["snapshots"]) == 1000
1378
1379 def test_list_default_limit_caps_at_20(self, tmp_path: pathlib.Path) -> None:
1380 """Default --limit of 20 caps a 50-snapshot store at 20 results."""
1381 _init_repo(tmp_path)
1382 for i in range(50):
1383 _write_snapshot(tmp_path, note=f"s{i}")
1384 result = _invoke(["snapshot", "list", "--json"], env=_env(tmp_path))
1385 assert result.exit_code == 0
1386 assert len(json.loads(result.output)["snapshots"]) == 20
1387
1388 def test_list_concurrent_reads_safe(self, tmp_path: pathlib.Path) -> None:
1389 """Concurrent _list_all_snapshots core calls on the same repo do not crash."""
1390 _init_repo(tmp_path)
1391 for i in range(10):
1392 _write_snapshot(tmp_path, note=f"c{i}")
1393 errors: list[str] = []
1394
1395 def _do_list() -> None:
1396 try:
1397 records = _list_all_snapshots(tmp_path)
1398 assert len(records) == 10
1399 except Exception as exc: # noqa: BLE001
1400 errors.append(str(exc))
1401
1402 threads = [threading.Thread(target=_do_list) for _ in range(15)]
1403 for t in threads:
1404 t.start()
1405 for t in threads:
1406 t.join()
1407 assert not errors, f"Concurrent failures: {errors}"
1408
1409
1410 # ---------------------------------------------------------------------------
1411 # Extended / Security / Stress tests for ``muse snapshot read``
1412 # ---------------------------------------------------------------------------
1413
1414
1415 class TestSnapshotReadExtended:
1416 """Unit, integration, and edge-case tests for ``muse snapshot read``."""
1417
1418 def test_read_help_contains_agent_quickstart(self) -> None:
1419 result = runner.invoke(cli, ["snapshot", "read", "--help"])
1420 assert result.exit_code == 0
1421 assert "quickstart" in result.output.lower() or "muse snapshot read" in result.output
1422
1423 def test_read_help_contains_json_schema(self) -> None:
1424 result = runner.invoke(cli, ["snapshot", "read", "--help"])
1425 assert result.exit_code == 0
1426 assert "snapshot_id" in result.output and "manifest" in result.output
1427
1428 def test_read_help_contains_exit_codes(self) -> None:
1429 result = runner.invoke(cli, ["snapshot", "read", "--help"])
1430 assert result.exit_code == 0
1431 assert "exit code" in result.output.lower() or "0 —" in result.output
1432
1433 def test_read_help_says_default_is_json(self) -> None:
1434 result = runner.invoke(cli, ["snapshot", "read", "--help"])
1435 assert result.exit_code == 0
1436 assert "json" in result.output.lower()
1437
1438 def test_read_default_is_json_no_flag_needed(self, tmp_path: pathlib.Path) -> None:
1439 """show with no flags emits valid JSON."""
1440 _init_repo(tmp_path)
1441 snap_id = _write_snapshot(tmp_path, note="default-json")
1442 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1443 assert result.exit_code == 0
1444 data: _ReadOut = json.loads(result.output)
1445 assert data["snapshot_id"] == snap_id
1446
1447 def test_read_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1448 """JSON output is compact (no indentation)."""
1449 _init_repo(tmp_path)
1450 snap_id = _write_snapshot(tmp_path)
1451 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1452 assert result.exit_code == 0
1453 assert "\n " not in result.output.strip()
1454
1455 def test_read_json_all_fields(self, tmp_path: pathlib.Path) -> None:
1456 """JSON output contains all five required fields."""
1457 _init_repo(tmp_path)
1458 snap_id = _write_snapshot(tmp_path, note="fields-check", n_files=3)
1459 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1460 assert result.exit_code == 0
1461 data: _ReadOut = json.loads(result.output)
1462 assert data["snapshot_id"] == snap_id
1463 assert "created_at" in data
1464 assert data["file_count"] == 3
1465 assert data["note"] == "fields-check"
1466 assert isinstance(data["manifest"], dict)
1467
1468 def test_read_manifest_sorted_alphabetically(self, tmp_path: pathlib.Path) -> None:
1469 """manifest keys in JSON output are sorted alphabetically."""
1470 _init_repo(tmp_path)
1471 manifest = {f"z_file_{i}.txt": blob_id(f"z{i}".encode()) for i in range(5)}
1472 manifest.update({f"a_file_{i}.txt": blob_id(f"a{i}".encode()) for i in range(5)})
1473 snap_id = hash_snapshot(manifest)
1474 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
1475 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1476 assert result.exit_code == 0
1477 data: _ReadOut = json.loads(result.output)
1478 keys = list(data["manifest"].keys())
1479 assert keys == sorted(keys)
1480
1481 def test_read_prefix_resolves_short_id(self, tmp_path: pathlib.Path) -> None:
1482 """A 12-char prefix resolves to the full snapshot."""
1483 _init_repo(tmp_path)
1484 snap_id = _write_snapshot(tmp_path, note="prefix-test")
1485 result = _invoke(["snapshot", "read", short_id(snap_id), "--json"], env=_env(tmp_path))
1486 assert result.exit_code == 0
1487 data: _ReadOut = json.loads(result.output)
1488 assert data["snapshot_id"] == snap_id
1489
1490 def test_read_not_found_exits_1(self, tmp_path: pathlib.Path) -> None:
1491 """Unknown snapshot ID exits with code 1."""
1492 _init_repo(tmp_path)
1493 result = _invoke(["snapshot", "read", "sha256:deadbeef"], env=_env(tmp_path))
1494 assert result.exit_code == 1
1495
1496 def test_read_not_found_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1497 """Not-found message goes to stderr (captured in output by CliRunner)."""
1498 _init_repo(tmp_path)
1499 result = _invoke(["snapshot", "read", "sha256:deadbeef"], env=_env(tmp_path))
1500 assert result.exit_code != 0
1501 assert "not found" in result.stderr.lower()
1502
1503 def test_read_text_flag_human_readable(self, tmp_path: pathlib.Path) -> None:
1504 """--text emits human-readable output (not JSON)."""
1505 _init_repo(tmp_path)
1506 snap_id = _write_snapshot(tmp_path, note="text-mode")
1507 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1508 assert result.exit_code == 0
1509 # Text output starts with "snapshot_id:" label, not a JSON brace
1510 assert not result.output.strip().startswith("{")
1511 assert "snapshot_id:" in result.output
1512
1513 def test_read_text_includes_note(self, tmp_path: pathlib.Path) -> None:
1514 """--text output includes the note label."""
1515 _init_repo(tmp_path)
1516 snap_id = _write_snapshot(tmp_path, note="my-note")
1517 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1518 assert result.exit_code == 0
1519 assert "my-note" in result.output
1520
1521 def test_read_text_lists_files(self, tmp_path: pathlib.Path) -> None:
1522 """--text output lists file names from the manifest."""
1523 _init_repo(tmp_path)
1524 snap_id = _write_snapshot(tmp_path, n_files=3)
1525 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1526 assert result.exit_code == 0
1527 assert "file_0.txt" in result.output
1528
1529 def test_read_file_count_matches_manifest(self, tmp_path: pathlib.Path) -> None:
1530 """file_count in JSON equals the number of keys in manifest."""
1531 _init_repo(tmp_path)
1532 snap_id = _write_snapshot(tmp_path, n_files=9)
1533 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1534 assert result.exit_code == 0
1535 data: _ReadOut = json.loads(result.output)
1536 assert data["file_count"] == len(data["manifest"])
1537
1538 def test_read_created_at_iso8601(self, tmp_path: pathlib.Path) -> None:
1539 """created_at field is ISO-8601 (contains 'T' separator)."""
1540 _init_repo(tmp_path)
1541 snap_id = _write_snapshot(tmp_path)
1542 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1543 assert result.exit_code == 0
1544 assert "T" in json.loads(result.output)["created_at"]
1545
1546 def test_read_note_empty_string_when_not_set(self, tmp_path: pathlib.Path) -> None:
1547 """note is empty string in JSON when not supplied at create time."""
1548 _init_repo(tmp_path)
1549 snap_id = _write_snapshot(tmp_path, note="")
1550 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1551 assert result.exit_code == 0
1552 assert json.loads(result.output)["note"] == ""
1553
1554 def test_read_text_no_note_line_when_empty(self, tmp_path: pathlib.Path) -> None:
1555 """--text output has no 'note:' line when note is empty."""
1556 _init_repo(tmp_path)
1557 snap_id = _write_snapshot(tmp_path, note="")
1558 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1559 assert result.exit_code == 0
1560 assert "note:" not in result.output.lower()
1561
1562
1563 class TestSnapshotReadSecurity:
1564 """Security tests for ``muse snapshot read``."""
1565
1566 def test_read_ansi_note_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1567 """ANSI in note is stripped from --text output."""
1568 _init_repo(tmp_path)
1569 malicious = "\x1b[31mDanger\x1b[0m"
1570 snap_id = _write_snapshot(tmp_path, note=malicious)
1571 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1572 assert result.exit_code == 0
1573 assert "\x1b[31m" not in result.output
1574
1575 def test_read_ansi_note_raw_in_json(self, tmp_path: pathlib.Path) -> None:
1576 """ANSI in note is preserved raw in JSON output (agent data)."""
1577 _init_repo(tmp_path)
1578 malicious = "\x1b[31mDanger\x1b[0m"
1579 snap_id = _write_snapshot(tmp_path, note=malicious)
1580 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1581 assert result.exit_code == 0
1582 assert json.loads(result.output)["note"] == malicious
1583
1584 def test_read_ansi_rel_path_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1585 """ANSI in a manifest path is stripped from --text output."""
1586 _init_repo(tmp_path)
1587 malicious_path = "\x1b[32msrc/malicious.py\x1b[0m"
1588 manifest = {malicious_path: blob_id(b"malicious")}
1589 snap_id = hash_snapshot(manifest)
1590 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
1591 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1592 assert result.exit_code == 0
1593 assert "\x1b[32m" not in result.output
1594
1595 def test_read_control_char_note_stripped_in_text(self, tmp_path: pathlib.Path) -> None:
1596 """CRLF in note is stripped from --text output."""
1597 _init_repo(tmp_path)
1598 snap_id = _write_snapshot(tmp_path, note="good\r\ninjected")
1599 result = _invoke(["snapshot", "read", snap_id], env=_env(tmp_path))
1600 assert result.exit_code == 0
1601 assert "\r" not in result.output
1602
1603 def test_read_not_found_id_sanitized_in_error(self, tmp_path: pathlib.Path) -> None:
1604 """ANSI in a not-found snapshot ID is stripped from the error message."""
1605 _init_repo(tmp_path)
1606 malicious_id = "\x1b[31mdeadbeef\x1b[0m"
1607 result = _invoke(["snapshot", "read", malicious_id], env=_env(tmp_path))
1608 assert result.exit_code != 0
1609 assert "\x1b[31m" not in result.output
1610
1611 def test_read_symlink_in_prefix_scan_skipped(self, tmp_path: pathlib.Path) -> None:
1612 """A symlink in the object store is skipped during prefix scan."""
1613 from muse.core.paths import objects_dir as _objects_dir
1614 _init_repo(tmp_path)
1615 real_id = _write_snapshot(tmp_path, note="real")
1616 objs_dir = _objects_dir(tmp_path)
1617 # Plant a symlink in the object store whose shard matches the real ID prefix.
1618 _, real_hex = split_id(real_id)
1619 shard_name = real_hex[:2]
1620 shard_dir = objs_dir / "sha256" / shard_name
1621 shard_dir.mkdir(parents=True, exist_ok=True)
1622 fake_name = real_hex[:4] + "f" * 58
1623 fake = shard_dir / fake_name
1624 try:
1625 fake.symlink_to("/etc/passwd")
1626 except (OSError, NotImplementedError):
1627 pytest.skip("symlinks not supported on this platform")
1628 # Full ID lookup should still work — symlink at different ID is irrelevant
1629 result = _invoke(["snapshot", "read", real_id, "--json"], env=_env(tmp_path))
1630 assert result.exit_code == 0
1631 assert json.loads(result.output)["note"] == "real"
1632
1633
1634 class TestSnapshotReadStress:
1635 """Stress tests for ``muse snapshot read``."""
1636
1637 def test_read_500_file_manifest_json(self, tmp_path: pathlib.Path) -> None:
1638 """show returns all 500 manifest entries for a large snapshot."""
1639 _init_repo(tmp_path)
1640 manifest = {f"f{i:04d}.dat": blob_id(f"data{i}".encode()) for i in range(500)}
1641 snap_id = hash_snapshot(manifest)
1642 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
1643 result = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1644 assert result.exit_code == 0
1645 data: _ReadOut = json.loads(result.output)
1646 assert data["file_count"] == 500
1647 assert len(data["manifest"]) == 500
1648
1649 def test_read_50_consecutive_shows(self, tmp_path: pathlib.Path) -> None:
1650 """50 consecutive show calls on different snapshots all succeed."""
1651 _init_repo(tmp_path)
1652 ids = [_write_snapshot(tmp_path, note=f"snap-{i}") for i in range(50)]
1653 for snap_id in ids:
1654 r = _invoke(["snapshot", "read", snap_id, "--json"], env=_env(tmp_path))
1655 assert r.exit_code == 0, f"Failed for {short_id(snap_id)}: {r.output}"
1656 assert json.loads(r.output)["snapshot_id"] == snap_id
1657
1658 def test_read_concurrent_reads_safe(self, tmp_path: pathlib.Path) -> None:
1659 """Concurrent _resolve_snapshot calls on the same snapshot do not crash."""
1660 _init_repo(tmp_path)
1661 snap_id = _write_snapshot(tmp_path, note="concurrent", n_files=5)
1662 errors: list[str] = []
1663
1664 def _do_show() -> None:
1665 try:
1666 rec = _resolve_snapshot(tmp_path, snap_id)
1667 assert rec is not None
1668 assert rec.snapshot_id == snap_id
1669 assert rec.note == "concurrent"
1670 except Exception as exc: # noqa: BLE001
1671 errors.append(str(exc))
1672
1673 threads = [threading.Thread(target=_do_show) for _ in range(20)]
1674 for t in threads:
1675 t.start()
1676 for t in threads:
1677 t.join()
1678 assert not errors, f"Concurrent failures: {errors}"
1679
1680
1681 # ---------------------------------------------------------------------------
1682 # Extended / Security / Stress tests for ``muse snapshot export``
1683 # ---------------------------------------------------------------------------
1684
1685
1686 class TestSnapshotExportExtended:
1687 """Unit, integration, and edge-case tests for ``muse snapshot export``."""
1688
1689 def test_export_help_contains_agent_quickstart(self) -> None:
1690 result = runner.invoke(cli, ["snapshot", "export", "--help"])
1691 assert result.exit_code == 0
1692 assert "quickstart" in result.output.lower() or "muse snapshot export" in result.output
1693
1694 def test_export_help_contains_json_schema(self) -> None:
1695 result = runner.invoke(cli, ["snapshot", "export", "--help"])
1696 assert result.exit_code == 0
1697 assert "size_bytes" in result.output
1698
1699 def test_export_help_contains_exit_codes(self) -> None:
1700 result = runner.invoke(cli, ["snapshot", "export", "--help"])
1701 assert result.exit_code == 0
1702 assert "exit code" in result.output.lower() or "0 —" in result.output
1703
1704 def test_export_j_alias(self, tmp_path: pathlib.Path) -> None:
1705 """-j is an alias for --json."""
1706 _init_repo(tmp_path)
1707 _create_files(tmp_path, 2)
1708 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1709 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1710 out_file = tmp_path / "alias.tar.gz"
1711 result = _invoke(
1712 ["snapshot", "export", snap_id, "--output", str(out_file), "-j"],
1713 env=_env(tmp_path),
1714 )
1715 assert result.exit_code == 0
1716 data: _ExportOut = json.loads(result.output)
1717 assert data["snapshot_id"] == snap_id
1718
1719 def test_export_tar_gz_default_format(self, tmp_path: pathlib.Path) -> None:
1720 """Default format is tar.gz; JSON reports format correctly."""
1721 _init_repo(tmp_path)
1722 _create_files(tmp_path, 1)
1723 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1724 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1725 out_file = tmp_path / "out.tar.gz"
1726 result = _invoke(
1727 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1728 env=_env(tmp_path),
1729 )
1730 assert result.exit_code == 0
1731 assert json.loads(result.output)["format"] == "tar.gz"
1732 assert tarfile.is_tarfile(str(out_file))
1733
1734 def test_export_zip_format(self, tmp_path: pathlib.Path) -> None:
1735 """--format zip writes a valid zip archive."""
1736 _init_repo(tmp_path)
1737 _create_files(tmp_path, 2)
1738 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1739 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1740 out_file = tmp_path / "out.zip"
1741 result = _invoke(
1742 ["snapshot", "export", snap_id, "--format", "zip", "--output", str(out_file), "--json"],
1743 env=_env(tmp_path),
1744 )
1745 assert result.exit_code == 0
1746 assert json.loads(result.output)["format"] == "zip"
1747 assert zipfile.is_zipfile(str(out_file))
1748
1749 def test_export_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1750 """JSON output contains all five required fields."""
1751 _init_repo(tmp_path)
1752 _create_files(tmp_path, 2)
1753 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1754 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1755 out_file = tmp_path / "fields.tar.gz"
1756 result = _invoke(
1757 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1758 env=_env(tmp_path),
1759 )
1760 assert result.exit_code == 0
1761 data: _ExportOut = json.loads(result.output)
1762 assert "snapshot_id" in data
1763 assert "output" in data
1764 assert "format" in data
1765 assert "file_count" in data
1766 assert "size_bytes" in data
1767
1768 def test_export_json_compact_no_indent(self, tmp_path: pathlib.Path) -> None:
1769 """JSON output is compact (no indentation)."""
1770 _init_repo(tmp_path)
1771 _create_files(tmp_path, 1)
1772 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1773 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1774 out_file = tmp_path / "compact.tar.gz"
1775 result = _invoke(
1776 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1777 env=_env(tmp_path),
1778 )
1779 assert result.exit_code == 0
1780 assert "\n " not in result.output.strip()
1781
1782 def test_export_size_bytes_positive(self, tmp_path: pathlib.Path) -> None:
1783 """size_bytes > 0 for a non-empty archive."""
1784 _init_repo(tmp_path)
1785 _create_files(tmp_path, 3)
1786 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1787 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1788 out_file = tmp_path / "size.tar.gz"
1789 result = _invoke(
1790 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1791 env=_env(tmp_path),
1792 )
1793 assert result.exit_code == 0
1794 assert json.loads(result.output)["size_bytes"] > 0
1795
1796 def test_export_file_count_matches(self, tmp_path: pathlib.Path) -> None:
1797 """file_count in JSON matches number of files created."""
1798 _init_repo(tmp_path)
1799 _create_files(tmp_path, 5)
1800 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1801 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1802 out_file = tmp_path / "count.tar.gz"
1803 result = _invoke(
1804 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1805 env=_env(tmp_path),
1806 )
1807 assert result.exit_code == 0
1808 assert json.loads(result.output)["file_count"] >= 5
1809
1810 def test_export_output_path_in_json(self, tmp_path: pathlib.Path) -> None:
1811 """output field in JSON matches the --output argument."""
1812 _init_repo(tmp_path)
1813 _create_files(tmp_path, 1)
1814 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1815 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1816 out_file = tmp_path / "myarchive.tar.gz"
1817 result = _invoke(
1818 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1819 env=_env(tmp_path),
1820 )
1821 assert result.exit_code == 0
1822 assert json.loads(result.output)["output"] == str(out_file)
1823
1824 def test_export_archive_actually_created(self, tmp_path: pathlib.Path) -> None:
1825 """The archive file is present on disk after export."""
1826 _init_repo(tmp_path)
1827 _create_files(tmp_path, 2)
1828 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1829 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1830 out_file = tmp_path / "present.tar.gz"
1831 result = _invoke(
1832 ["snapshot", "export", snap_id, "--output", str(out_file)],
1833 env=_env(tmp_path),
1834 )
1835 assert result.exit_code == 0
1836 assert out_file.exists()
1837
1838 def test_export_prefix_nests_files_in_tar(self, tmp_path: pathlib.Path) -> None:
1839 """--prefix nests all files under a directory inside the tar archive."""
1840 _init_repo(tmp_path)
1841 _create_files(tmp_path, 2)
1842 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1843 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1844 out_file = tmp_path / "prefixed.tar.gz"
1845 result = _invoke(
1846 ["snapshot", "export", snap_id, "--output", str(out_file), "--prefix", "mydir"],
1847 env=_env(tmp_path),
1848 )
1849 assert result.exit_code == 0
1850 with tarfile.open(str(out_file)) as tf:
1851 names = tf.getnames()
1852 assert all(n.startswith("mydir/") for n in names)
1853
1854 def test_export_not_found_exits_1(self, tmp_path: pathlib.Path) -> None:
1855 """Unknown snapshot ID exits with code 1."""
1856 _init_repo(tmp_path)
1857 out_file = tmp_path / "nope.tar.gz"
1858 result = _invoke(
1859 ["snapshot", "export", "deadbeef", "--output", str(out_file)],
1860 env=_env(tmp_path),
1861 )
1862 assert result.exit_code == 1
1863
1864 def test_export_prefix_scan_resolves_short_id(self, tmp_path: pathlib.Path) -> None:
1865 """A 12-char prefix resolves to the correct snapshot for export."""
1866 _init_repo(tmp_path)
1867 _create_files(tmp_path, 1)
1868 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1869 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1870 out_file = tmp_path / "prefix_resolve.tar.gz"
1871 result = _invoke(
1872 ["snapshot", "export", short_id(snap_id), "--output", str(out_file), "--json"],
1873 env=_env(tmp_path),
1874 )
1875 assert result.exit_code == 0
1876 assert json.loads(result.output)["snapshot_id"] == snap_id
1877
1878 def test_export_snapshot_id_in_json_is_full_hex(self, tmp_path: pathlib.Path) -> None:
1879 """snapshot_id in JSON is the full 64-char hex ID."""
1880 _init_repo(tmp_path)
1881 _create_files(tmp_path, 1)
1882 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1883 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1884 out_file = tmp_path / "id_check.tar.gz"
1885 result = _invoke(
1886 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1887 env=_env(tmp_path),
1888 )
1889 assert result.exit_code == 0
1890 sid = json.loads(result.output)["snapshot_id"]
1891 assert len(sid) == 71
1892 assert all(c in "0123456789abcdef" for c in split_id(sid)[1])
1893
1894 def test_export_text_output_mentions_path(self, tmp_path: pathlib.Path) -> None:
1895 """Text output mentions the archive filename."""
1896 _init_repo(tmp_path)
1897 _create_files(tmp_path, 1)
1898 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1899 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1900 out_file = tmp_path / "mentioned.tar.gz"
1901 result = _invoke(
1902 ["snapshot", "export", snap_id, "--output", str(out_file)],
1903 env=_env(tmp_path),
1904 )
1905 assert result.exit_code == 0
1906 assert "mentioned.tar.gz" in result.output
1907
1908
1909 class TestSnapshotExportSecurity:
1910 """Security tests for ``muse snapshot export``."""
1911
1912 def test_export_not_found_id_sanitized(self, tmp_path: pathlib.Path) -> None:
1913 """ANSI in a not-found snapshot ID is stripped from the error message."""
1914 _init_repo(tmp_path)
1915 malicious_id = "\x1b[31mdeadbeef\x1b[0m"
1916 out_file = tmp_path / "nope.tar.gz"
1917 result = _invoke(
1918 ["snapshot", "export", malicious_id, "--output", str(out_file)],
1919 env=_env(tmp_path),
1920 )
1921 assert result.exit_code != 0
1922 assert "\x1b[31m" not in result.output
1923
1924 def test_export_text_output_no_ansi(self, tmp_path: pathlib.Path) -> None:
1925 """Normal text output from export contains no ANSI escape sequences."""
1926 _init_repo(tmp_path)
1927 _create_files(tmp_path, 1)
1928 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1929 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1930 out_file = tmp_path / "clean.tar.gz"
1931 result = _invoke(
1932 ["snapshot", "export", snap_id, "--output", str(out_file)],
1933 env=_env(tmp_path),
1934 )
1935 assert result.exit_code == 0
1936 assert "\x1b[" not in result.output
1937
1938 def test_export_zip_slip_dotdot_skipped(self, tmp_path: pathlib.Path) -> None:
1939 """A manifest entry with '..' segments is skipped (zip-slip guard)."""
1940 _init_repo(tmp_path)
1941 malicious_path = "../../../etc/passwd"
1942 obj_data = b"malicious content"
1943 obj_id = blob_id(obj_data)
1944 write_object(tmp_path, obj_id, obj_data)
1945 manifest = {malicious_path: obj_id}
1946 snap_id = hash_snapshot(manifest)
1947 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
1948 out_file = tmp_path / "slip.tar.gz"
1949 result = _invoke(
1950 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1951 env=_env(tmp_path),
1952 )
1953 assert result.exit_code == 0
1954 assert json.loads(result.output)["file_count"] == 0
1955
1956 def test_export_zip_slip_absolute_skipped(self, tmp_path: pathlib.Path) -> None:
1957 """A manifest entry with an absolute path is skipped (zip-slip guard)."""
1958 _init_repo(tmp_path)
1959 obj_data = b"absolute malicious"
1960 obj_id = blob_id(obj_data)
1961 write_object(tmp_path, obj_id, obj_data)
1962 manifest = {"/etc/passwd": obj_id}
1963 snap_id = hash_snapshot(manifest)
1964 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
1965 out_file = tmp_path / "abs.tar.gz"
1966 result = _invoke(
1967 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1968 env=_env(tmp_path),
1969 )
1970 assert result.exit_code == 0
1971 assert json.loads(result.output)["file_count"] == 0
1972
1973 def test_export_prefix_dotdot_skipped(self, tmp_path: pathlib.Path) -> None:
1974 """A --prefix containing '..' causes all entries to be skipped."""
1975 _init_repo(tmp_path)
1976 _create_files(tmp_path, 1)
1977 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
1978 snap_id: str = json.loads(create_res.output)["snapshot_id"]
1979 out_file = tmp_path / "dotdot.tar.gz"
1980 result = _invoke(
1981 ["snapshot", "export", snap_id, "--output", str(out_file),
1982 "--prefix", "../escape", "--json"],
1983 env=_env(tmp_path),
1984 )
1985 assert result.exit_code == 0
1986 assert json.loads(result.output)["file_count"] == 0
1987
1988 def test_export_missing_object_skipped(self, tmp_path: pathlib.Path) -> None:
1989 """A manifest entry whose object is missing from the store is skipped."""
1990 _init_repo(tmp_path)
1991 ghost_id = blob_id(b"ghost")
1992 manifest = {"ghost.txt": ghost_id}
1993 snap_id = hash_snapshot(manifest)
1994 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
1995 out_file = tmp_path / "ghost.tar.gz"
1996 result = _invoke(
1997 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
1998 env=_env(tmp_path),
1999 )
2000 assert result.exit_code == 0
2001 assert json.loads(result.output)["file_count"] == 0
2002
2003
2004 class TestSnapshotExportStress:
2005 """Stress tests for ``muse snapshot export``."""
2006
2007 def test_export_500_file_tar_gz(self, tmp_path: pathlib.Path) -> None:
2008 """Export of a 500-file snapshot produces a valid tar.gz with all files."""
2009 _init_repo(tmp_path)
2010 manifest: Manifest = {}
2011 for i in range(500):
2012 data = f"content-{i}".encode()
2013 obj_id = blob_id(data)
2014 write_object(tmp_path, obj_id, data)
2015 manifest[f"f{i:04d}.dat"] = obj_id
2016 snap_id = hash_snapshot(manifest)
2017 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
2018 out_file = tmp_path / "big.tar.gz"
2019 result = _invoke(
2020 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
2021 env=_env(tmp_path),
2022 )
2023 assert result.exit_code == 0
2024 data_out: _ExportOut = json.loads(result.output)
2025 assert data_out["file_count"] == 500
2026 assert data_out["size_bytes"] > 0
2027 assert tarfile.is_tarfile(str(out_file))
2028
2029 def test_export_500_file_zip(self, tmp_path: pathlib.Path) -> None:
2030 """Export of a 500-file snapshot produces a valid zip with all files."""
2031 _init_repo(tmp_path)
2032 manifest: Manifest = {}
2033 for i in range(500):
2034 data = f"zip-content-{i}".encode()
2035 obj_id = blob_id(data)
2036 write_object(tmp_path, obj_id, data)
2037 manifest[f"z{i:04d}.dat"] = obj_id
2038 snap_id = hash_snapshot(manifest)
2039 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
2040 out_file = tmp_path / "big.zip"
2041 result = _invoke(
2042 ["snapshot", "export", snap_id, "--format", "zip", "--output", str(out_file), "--json"],
2043 env=_env(tmp_path),
2044 )
2045 assert result.exit_code == 0
2046 data_out: _ExportOut = json.loads(result.output)
2047 assert data_out["file_count"] == 500
2048 assert zipfile.is_zipfile(str(out_file))
2049
2050 def test_export_10_consecutive_exports_same_snapshot(self, tmp_path: pathlib.Path) -> None:
2051 """10 consecutive exports of the same snapshot all succeed with consistent results."""
2052 _init_repo(tmp_path)
2053 _create_files(tmp_path, 5)
2054 create_res = _invoke(["snapshot", "create", "--json"], env=_env(tmp_path))
2055 snap_id: str = json.loads(create_res.output)["snapshot_id"]
2056 for i in range(10):
2057 out_file = tmp_path / f"repeat_{i}.tar.gz"
2058 result = _invoke(
2059 ["snapshot", "export", snap_id, "--output", str(out_file), "--json"],
2060 env=_env(tmp_path),
2061 )
2062 assert result.exit_code == 0, f"Iteration {i} failed: {result.output}"
2063 data_out: _ExportOut = json.loads(result.output)
2064 assert data_out["snapshot_id"] == snap_id
2065 assert data_out["file_count"] >= 5
2066
2067
2068 # ---------------------------------------------------------------------------
2069 # Flag registration tests
2070 # ---------------------------------------------------------------------------
2071
2072
2073 class TestRegisterFlags:
2074 def _parser(self) -> "argparse.ArgumentParser":
2075 import argparse
2076 from muse.cli.commands.snapshot_cmd import register
2077
2078 p = argparse.ArgumentParser()
2079 subs = p.add_subparsers()
2080 register(subs)
2081 return p
2082
2083 def test_default_json_out_is_false(self) -> None:
2084 args = self._parser().parse_args(["snapshot", "create"])
2085 assert args.json_out is False
2086
2087 def test_json_flag_sets_json_out(self) -> None:
2088 args = self._parser().parse_args(["snapshot", "create", "--json"])
2089 assert args.json_out is True
2090
2091 def test_j_shorthand_sets_json_out(self) -> None:
2092 args = self._parser().parse_args(["snapshot", "create", "-j"])
2093 assert args.json_out is True
File History 1 commit
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385 refactor: rename StructuredMergePlugin to AddressedMergePlu… Sonnet 4.6 minor 23 days ago