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