gabriel / muse public
test_archive_command.py python
833 lines 33.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for ``muse archive`` — snapshot export command.
2
3 Tiers
4 -----
5 1. Unit — ``_safe_arcname`` and ``_build_entries`` in isolation.
6 2. Integration — store round-trip: write commit/snapshot, build archive, verify contents.
7 3. End-to-End — full CLI invocations via CliRunner.
8 4. Security — zip-slip, tar-slip, null bytes, ``..`` traversal, unsafe prefixes.
9 5. Stress — large manifests, many files, names at path limits.
10 6. Performance — timing assertions on archive creation and list mode.
11 7. Data Integrity — archive contents match snapshot manifest exactly; JSON schema complete.
12 """
13
14 from __future__ import annotations
15
16 import datetime
17 import json
18 import pathlib
19 import tarfile
20 import time
21 import zipfile
22
23 import pytest
24 from tests.cli_test_helper import CliRunner
25
26 cli = None # argparse migration — CliRunner ignores this arg
27
28 from muse.cli.commands.archive import (
29 _FORMAT_CHOICES,
30 _build_entries,
31 _build_tar,
32 _build_zip,
33 _safe_arcname,
34 )
35 from muse.core.object_store import write_object
36 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
37 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
38 from muse.core._types import blob_id, long_id, short_id, fake_id
39
40 runner = CliRunner()
41
42
43 # ---------------------------------------------------------------------------
44 # Fixtures
45 # ---------------------------------------------------------------------------
46
47
48 @pytest.fixture
49 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
50 """Minimal Muse repo chdir'd into tmp_path."""
51 monkeypatch.chdir(tmp_path)
52 muse = tmp_path / ".muse"
53 muse.mkdir()
54 (muse / "repo.json").write_text('{"repo_id":"test-repo"}')
55 (muse / "HEAD").write_text("ref: refs/heads/main")
56 (muse / "commits").mkdir()
57 (muse / "snapshots").mkdir()
58 (muse / "refs" / "heads").mkdir(parents=True)
59 (muse / "objects").mkdir()
60 return tmp_path
61
62
63 def _make_commit(
64 root: pathlib.Path,
65 files: dict[str, bytes],
66 message: str = "test commit",
67 ) -> CommitRecord:
68 """Write objects, a snapshot, and a commit; update the branch ref.
69
70 Args:
71 root: Repository root.
72 files: Mapping of relative path → raw file bytes.
73 message: Commit message.
74
75 Returns:
76 The written ``CommitRecord``.
77 """
78 manifest: dict[str, str] = {}
79 for rel_path, content in files.items():
80 oid = blob_id(content)
81 write_object(root, oid, content)
82 manifest[rel_path] = oid
83
84 snap_id = compute_snapshot_id(manifest)
85 snap = SnapshotRecord(
86 snapshot_id=snap_id,
87 manifest=manifest,
88 directories=[],
89 created_at=datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc),
90 note="",
91 )
92 write_snapshot(root, snap)
93
94 committed_at = datetime.datetime(2026, 3, 1, tzinfo=datetime.timezone.utc)
95 cid = compute_commit_id(
96 repo_id="test-repo",
97 parent_ids=[],
98 snapshot_id=snap_id,
99 message=message,
100 committed_at_iso=committed_at.isoformat(),
101 author="test-author",
102 )
103 record = CommitRecord(
104 commit_id=cid,
105 repo_id="test-repo",
106 created_on_branch="main",
107 snapshot_id=snap_id,
108 message=message,
109 committed_at=committed_at,
110 author="test-author",
111 agent_id="test-agent",
112 model_id="test-model",
113 )
114 write_commit(root, record)
115 (root / ".muse" / "refs" / "heads" / "main").write_text(cid)
116 return record
117
118
119 # ===========================================================================
120 # 1. Unit tests — _safe_arcname and _build_entries
121 # ===========================================================================
122
123
124 class TestSafeArcname:
125 def test_simple_path_no_prefix(self) -> None:
126 assert _safe_arcname("", "src/main.py") == "src/main.py"
127
128 def test_simple_path_with_prefix(self) -> None:
129 assert _safe_arcname("myproject", "src/main.py") == "myproject/src/main.py"
130
131 def test_prefix_trailing_slash_stripped(self) -> None:
132 assert _safe_arcname("myproject/", "a.py") == "myproject/a.py"
133
134 def test_empty_rel_path_returns_none(self) -> None:
135 assert _safe_arcname("", "") is None
136
137 def test_dot_rel_path_returns_none(self) -> None:
138 # PurePosixPath("") → "." — should be rejected
139 assert _safe_arcname("", ".") is None
140
141 def test_absolute_rel_path_returns_none(self) -> None:
142 assert _safe_arcname("", "/etc/passwd") is None
143
144 def test_dotdot_in_rel_path_returns_none(self) -> None:
145 assert _safe_arcname("", "../../etc/passwd") is None
146
147 def test_dotdot_component_in_rel_path_returns_none(self) -> None:
148 assert _safe_arcname("", "src/../../../etc/passwd") is None
149
150 def test_dotdot_in_prefix_returns_none(self) -> None:
151 assert _safe_arcname("../evil", "a.py") is None
152
153 def test_null_byte_in_rel_path_returns_none(self) -> None:
154 assert _safe_arcname("", "a\x00b.py") is None
155
156 def test_null_byte_in_prefix_returns_none(self) -> None:
157 assert _safe_arcname("pre\x00fix", "a.py") is None
158
159 def test_nested_path(self) -> None:
160 assert _safe_arcname("", "a/b/c/d.txt") == "a/b/c/d.txt"
161
162 def test_single_filename(self) -> None:
163 assert _safe_arcname("", "README.md") == "README.md"
164
165 def test_prefix_with_subdirs(self) -> None:
166 assert _safe_arcname("proj/v2", "src/app.py") == "proj/v2/src/app.py"
167
168
169 class TestBuildEntries:
170 def test_returns_entries_for_valid_manifest(self, repo: pathlib.Path) -> None:
171 c = _make_commit(repo, {"a.py": b"hello"})
172 from muse.core.store import read_snapshot, read_commit
173 commit = read_commit(repo, c.commit_id)
174 assert commit is not None
175 snap = read_snapshot(repo, commit.snapshot_id)
176 assert snap is not None
177 entries, skipped = _build_entries(repo, snap.manifest, "")
178 assert len(entries) == 1
179 assert skipped == []
180 arcname, oid, path = entries[0]
181 assert arcname == "a.py"
182 assert path.exists()
183
184 def test_skips_missing_objects(self, repo: pathlib.Path) -> None:
185 # Fake a manifest entry pointing at a nonexistent object.
186 fake_manifest = {"ghost.py": fake_id("ghost-obj")}
187 entries, skipped = _build_entries(repo, fake_manifest, "")
188 assert entries == []
189 assert len(skipped) == 1
190 assert "missing" in skipped[0]
191
192 def test_entries_sorted_by_arcname(self, repo: pathlib.Path) -> None:
193 c = _make_commit(repo, {"z.py": b"z", "a.py": b"a", "m.py": b"m"})
194 from muse.core.store import read_snapshot, read_commit
195 commit = read_commit(repo, c.commit_id)
196 snap = read_snapshot(repo, commit.snapshot_id)
197 entries, _ = _build_entries(repo, snap.manifest, "")
198 names = [e[0] for e in entries]
199 assert names == sorted(names)
200
201 def test_prefix_applied_to_arcnames(self, repo: pathlib.Path) -> None:
202 c = _make_commit(repo, {"src/app.py": b"app"})
203 from muse.core.store import read_snapshot, read_commit
204 commit = read_commit(repo, c.commit_id)
205 snap = read_snapshot(repo, commit.snapshot_id)
206 entries, _ = _build_entries(repo, snap.manifest, "myproject")
207 assert entries[0][0] == "myproject/src/app.py"
208
209
210 # ===========================================================================
211 # 2. Integration tests — store round-trip + archive contents
212 # ===========================================================================
213
214
215 class TestTarContents:
216 def test_tar_contains_all_files(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
217 _make_commit(repo, {"a.py": b"aaa", "b.py": b"bbb"})
218 out = tmp_path / "out.tar.gz"
219 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
220 with tarfile.open(out, "r:gz") as tar:
221 names = tar.getnames()
222 assert "a.py" in names
223 assert "b.py" in names
224
225 def test_tar_file_contents_match_source(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
226 _make_commit(repo, {"hello.py": b"print('hello')"})
227 out = tmp_path / "out.tar.gz"
228 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
229 with tarfile.open(out, "r:gz") as tar:
230 member = tar.getmember("hello.py")
231 f = tar.extractfile(member)
232 assert f is not None
233 assert f.read() == b"print('hello')"
234
235 def test_tar_prefix_wraps_files(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
236 _make_commit(repo, {"a.py": b"a"})
237 out = tmp_path / "out.tar.gz"
238 runner.invoke(
239 cli, ["archive", "--prefix", "proj", "--output", str(out)],
240 catch_exceptions=False,
241 )
242 with tarfile.open(out, "r:gz") as tar:
243 names = tar.getnames()
244 assert "proj/a.py" in names
245 assert "a.py" not in names
246
247 def test_no_muse_metadata_in_tar(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
248 _make_commit(repo, {"src/app.py": b"app"})
249 out = tmp_path / "out.tar.gz"
250 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
251 with tarfile.open(out, "r:gz") as tar:
252 names = tar.getnames()
253 assert not any(".muse" in n for n in names)
254
255
256 class TestZipContents:
257 def test_zip_contains_all_files(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
258 _make_commit(repo, {"x.py": b"x", "y.py": b"y"})
259 out = tmp_path / "out.zip"
260 runner.invoke(cli, ["archive", "--format", "zip", "--output", str(out)], catch_exceptions=False)
261 with zipfile.ZipFile(out) as zf:
262 names = zf.namelist()
263 assert "x.py" in names
264 assert "y.py" in names
265
266 def test_zip_file_contents_match_source(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
267 _make_commit(repo, {"data.txt": b"hello world"})
268 out = tmp_path / "out.zip"
269 runner.invoke(cli, ["archive", "--format", "zip", "--output", str(out)], catch_exceptions=False)
270 with zipfile.ZipFile(out) as zf:
271 assert zf.read("data.txt") == b"hello world"
272
273 def test_zip_prefix_wraps_files(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
274 _make_commit(repo, {"b.py": b"b"})
275 out = tmp_path / "out.zip"
276 runner.invoke(
277 cli, ["archive", "--format", "zip", "--prefix", "release", "--output", str(out)],
278 catch_exceptions=False,
279 )
280 with zipfile.ZipFile(out) as zf:
281 names = zf.namelist()
282 assert "release/b.py" in names
283 assert "b.py" not in names
284
285
286 # ===========================================================================
287 # 3. End-to-End tests — full CLI
288 # ===========================================================================
289
290
291 class TestDefaultBehavior:
292 def test_exits_0_with_commit(self, repo: pathlib.Path) -> None:
293 _make_commit(repo, {"a.py": b"a"})
294 result = runner.invoke(cli, ["archive"], catch_exceptions=False)
295 assert result.exit_code == 0
296
297 def test_default_filename_no_sha256_prefix(self, repo: pathlib.Path) -> None:
298 c = _make_commit(repo, {"a.py": b"a"})
299 runner.invoke(cli, ["archive"], catch_exceptions=False)
300 hex_short = short_id(c.commit_id, strip=True)
301 assert pathlib.Path(f"{hex_short}.tar.gz").exists()
302
303 def test_default_filename_has_no_colon(self, repo: pathlib.Path) -> None:
304 _make_commit(repo, {"a.py": b"a"})
305 runner.invoke(cli, ["archive"], catch_exceptions=False)
306 created = list(pathlib.Path(".").glob("*.tar.gz"))
307 assert created, "no tar.gz file created"
308 assert ":" not in created[0].name
309
310 def test_no_commits_exits_1(self, repo: pathlib.Path) -> None:
311 result = runner.invoke(cli, ["archive"])
312 assert result.exit_code != 0
313
314 def test_output_includes_file_count(self, repo: pathlib.Path) -> None:
315 _make_commit(repo, {"a.py": b"a", "b.py": b"b"})
316 result = runner.invoke(cli, ["archive"], catch_exceptions=False)
317 assert "2 file(s)" in result.output
318
319 def test_output_includes_commit_short(self, repo: pathlib.Path) -> None:
320 c = _make_commit(repo, {"a.py": b"a"})
321 result = runner.invoke(cli, ["archive"], catch_exceptions=False)
322 short = short_id(c.commit_id, strip=True)
323 assert short in result.output
324
325
326 class TestFormatFlag:
327 def test_zip_format_flag(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
328 _make_commit(repo, {"a.py": b"a"})
329 out = tmp_path / "out.zip"
330 result = runner.invoke(
331 cli, ["archive", "--format", "zip", "--output", str(out)],
332 catch_exceptions=False,
333 )
334 assert result.exit_code == 0
335 assert zipfile.is_zipfile(out)
336
337 def test_tgz_short_flag(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
338 _make_commit(repo, {"a.py": b"a"})
339 out = tmp_path / "out.tar.gz"
340 result = runner.invoke(
341 cli, ["archive", "-f", "tar.gz", "--output", str(out)],
342 catch_exceptions=False,
343 )
344 assert result.exit_code == 0
345 assert tarfile.is_tarfile(out)
346
347 def test_invalid_format_exits_nonzero(self, repo: pathlib.Path) -> None:
348 _make_commit(repo, {"a.py": b"a"})
349 result = runner.invoke(cli, ["archive", "--format", "rar"])
350 assert result.exit_code != 0
351
352
353 class TestRefFlag:
354 def test_ref_to_branch(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
355 _make_commit(repo, {"a.py": b"a"}, message="on main")
356 out = tmp_path / "out.tar.gz"
357 result = runner.invoke(
358 cli, ["archive", "--ref", "main", "--output", str(out)],
359 catch_exceptions=False,
360 )
361 assert result.exit_code == 0
362
363 def test_ref_to_commit_id(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
364 c = _make_commit(repo, {"a.py": b"a"})
365 out = tmp_path / "out.tar.gz"
366 short = c.commit_id[len("sha256:"):len("sha256:") + 8]
367 result = runner.invoke(
368 cli, ["archive", "--ref", short, "--output", str(out)],
369 catch_exceptions=False,
370 )
371 assert result.exit_code == 0
372
373 def test_unknown_ref_exits_1(self, repo: pathlib.Path) -> None:
374 _make_commit(repo, {"a.py": b"a"})
375 result = runner.invoke(cli, ["archive", "--ref", "no-such-branch"])
376 assert result.exit_code != 0
377
378
379 class TestOutputFlag:
380 def test_custom_output_path(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
381 _make_commit(repo, {"a.py": b"a"})
382 out = tmp_path / "release.tar.gz"
383 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
384 assert out.exists()
385
386 def test_output_short_flag(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
387 _make_commit(repo, {"a.py": b"a"})
388 out = tmp_path / "r.tar.gz"
389 result = runner.invoke(
390 cli, ["archive", "-o", str(out)], catch_exceptions=False
391 )
392 assert result.exit_code == 0
393 assert out.exists()
394
395 def test_missing_output_dir_exits_1(self, repo: pathlib.Path) -> None:
396 _make_commit(repo, {"a.py": b"a"})
397 result = runner.invoke(cli, ["archive", "--output", "/nonexistent/dir/out.tar.gz"])
398 assert result.exit_code != 0
399
400
401 class TestListMode:
402 def test_list_exits_0(self, repo: pathlib.Path) -> None:
403 _make_commit(repo, {"a.py": b"a"})
404 result = runner.invoke(cli, ["archive", "--list"], catch_exceptions=False)
405 assert result.exit_code == 0
406
407 def test_list_does_not_create_file(self, repo: pathlib.Path) -> None:
408 _make_commit(repo, {"a.py": b"a"})
409 before = set(pathlib.Path(".").glob("*.tar.gz"))
410 runner.invoke(cli, ["archive", "--list"], catch_exceptions=False)
411 after = set(pathlib.Path(".").glob("*.tar.gz"))
412 assert before == after
413
414 def test_list_shows_file_paths(self, repo: pathlib.Path) -> None:
415 _make_commit(repo, {"src/app.py": b"app", "README.md": b"readme"})
416 result = runner.invoke(cli, ["archive", "--list"], catch_exceptions=False)
417 assert "src/app.py" in result.output
418 assert "README.md" in result.output
419
420 def test_list_shows_file_count(self, repo: pathlib.Path) -> None:
421 _make_commit(repo, {"a.py": b"a", "b.py": b"b", "c.py": b"c"})
422 result = runner.invoke(cli, ["archive", "--list"], catch_exceptions=False)
423 assert "3 file(s)" in result.output
424
425 def test_list_with_prefix_shows_prefixed_paths(self, repo: pathlib.Path) -> None:
426 _make_commit(repo, {"a.py": b"a"})
427 result = runner.invoke(
428 cli, ["archive", "--list", "--prefix", "proj"],
429 catch_exceptions=False,
430 )
431 assert "proj/a.py" in result.output
432
433 def test_list_json_schema(self, repo: pathlib.Path) -> None:
434 _make_commit(repo, {"a.py": b"a"})
435 result = runner.invoke(
436 cli, ["archive", "--list", "--json"], catch_exceptions=False
437 )
438 data = json.loads(result.output)
439 required = {
440 "commit_id", "snapshot_id", "message", "branch", "author",
441 "committed_at", "ref", "prefix", "file_count", "entries",
442 }
443 assert required <= data.keys()
444 assert isinstance(data["entries"], list)
445 assert data["entries"][0].keys() >= {"path", "object_id"}
446
447 def test_list_json_entry_count_matches(self, repo: pathlib.Path) -> None:
448 _make_commit(repo, {"a.py": b"a", "b.py": b"b"})
449 result = runner.invoke(
450 cli, ["archive", "--list", "--json"], catch_exceptions=False
451 )
452 data = json.loads(result.output)
453 assert data["file_count"] == 2
454 assert len(data["entries"]) == 2
455
456
457 class TestJsonOutput:
458 def test_json_exits_0(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
459 _make_commit(repo, {"a.py": b"a"})
460 out = tmp_path / "out.tar.gz"
461 result = runner.invoke(
462 cli, ["archive", "--json", "--output", str(out)],
463 catch_exceptions=False,
464 )
465 assert result.exit_code == 0
466
467 def test_json_is_valid(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
468 _make_commit(repo, {"a.py": b"a"})
469 out = tmp_path / "out.tar.gz"
470 result = runner.invoke(
471 cli, ["archive", "--json", "--output", str(out)],
472 catch_exceptions=False,
473 )
474 data = json.loads(result.output)
475 assert isinstance(data, dict)
476
477 def test_json_has_all_keys(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
478 _make_commit(repo, {"a.py": b"a"})
479 out = tmp_path / "out.tar.gz"
480 result = runner.invoke(
481 cli, ["archive", "--json", "--output", str(out)],
482 catch_exceptions=False,
483 )
484 data = json.loads(result.output)
485 required = {
486 "path", "format", "file_count", "bytes",
487 "commit_id", "snapshot_id", "message", "branch",
488 "author", "agent_id", "model_id", "committed_at",
489 "ref", "prefix",
490 }
491 assert required <= data.keys()
492
493 def test_json_file_count_correct(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
494 _make_commit(repo, {"a.py": b"a", "b.py": b"b"})
495 out = tmp_path / "out.tar.gz"
496 result = runner.invoke(
497 cli, ["archive", "--json", "--output", str(out)],
498 catch_exceptions=False,
499 )
500 data = json.loads(result.output)
501 assert data["file_count"] == 2
502
503 def test_json_bytes_positive(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
504 _make_commit(repo, {"a.py": b"some content here"})
505 out = tmp_path / "out.tar.gz"
506 result = runner.invoke(
507 cli, ["archive", "--json", "--output", str(out)],
508 catch_exceptions=False,
509 )
510 data = json.loads(result.output)
511 assert data["bytes"] > 0
512
513 def test_json_commit_id_matches(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
514 c = _make_commit(repo, {"a.py": b"a"})
515 out = tmp_path / "out.tar.gz"
516 result = runner.invoke(
517 cli, ["archive", "--json", "--output", str(out)],
518 catch_exceptions=False,
519 )
520 data = json.loads(result.output)
521 assert data["commit_id"] == c.commit_id
522
523 def test_json_snapshot_id_present(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
524 c = _make_commit(repo, {"a.py": b"a"})
525 out = tmp_path / "out.tar.gz"
526 result = runner.invoke(
527 cli, ["archive", "--json", "--output", str(out)],
528 catch_exceptions=False,
529 )
530 data = json.loads(result.output)
531 assert data["snapshot_id"] == c.snapshot_id
532
533 def test_json_agent_id_and_model_id(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
534 _make_commit(repo, {"a.py": b"a"})
535 out = tmp_path / "out.tar.gz"
536 result = runner.invoke(
537 cli, ["archive", "--json", "--output", str(out)],
538 catch_exceptions=False,
539 )
540 data = json.loads(result.output)
541 assert data["agent_id"] == "test-agent"
542 assert data["model_id"] == "test-model"
543
544 def test_json_ref_null_for_head(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
545 _make_commit(repo, {"a.py": b"a"})
546 out = tmp_path / "out.tar.gz"
547 result = runner.invoke(
548 cli, ["archive", "--json", "--output", str(out)],
549 catch_exceptions=False,
550 )
551 data = json.loads(result.output)
552 assert data["ref"] is None
553
554 def test_json_ref_set_when_given(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
555 _make_commit(repo, {"a.py": b"a"})
556 out = tmp_path / "out.tar.gz"
557 result = runner.invoke(
558 cli, ["archive", "--json", "--ref", "main", "--output", str(out)],
559 catch_exceptions=False,
560 )
561 data = json.loads(result.output)
562 assert data["ref"] == "main"
563
564 def test_json_prefix_field(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
565 _make_commit(repo, {"a.py": b"a"})
566 out = tmp_path / "out.tar.gz"
567 result = runner.invoke(
568 cli, ["archive", "--json", "--prefix", "myproj", "--output", str(out)],
569 catch_exceptions=False,
570 )
571 data = json.loads(result.output)
572 assert data["prefix"] == "myproj"
573
574 def test_json_format_field(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
575 _make_commit(repo, {"a.py": b"a"})
576 out = tmp_path / "out.zip"
577 result = runner.invoke(
578 cli, ["archive", "--json", "--format", "zip", "--output", str(out)],
579 catch_exceptions=False,
580 )
581 data = json.loads(result.output)
582 assert data["format"] == "zip"
583
584
585 # ===========================================================================
586 # 4. Security tests
587 # ===========================================================================
588
589
590 class TestSecurity:
591 def test_safe_arcname_blocks_traversal(self) -> None:
592 assert _safe_arcname("", "../../etc/passwd") is None
593
594 def test_safe_arcname_blocks_absolute(self) -> None:
595 assert _safe_arcname("", "/etc/passwd") is None
596
597 def test_safe_arcname_blocks_null_byte_path(self) -> None:
598 assert _safe_arcname("", "a\x00b") is None
599
600 def test_safe_arcname_blocks_null_byte_prefix(self) -> None:
601 assert _safe_arcname("pre\x00fix", "a.py") is None
602
603 def test_safe_arcname_blocks_dotdot_prefix(self) -> None:
604 assert _safe_arcname("../../evil", "a.py") is None
605
606 def test_prefix_dotdot_rejected_by_cli(self, repo: pathlib.Path) -> None:
607 _make_commit(repo, {"a.py": b"a"})
608 result = runner.invoke(cli, ["archive", "--prefix", "../../etc"])
609 assert result.exit_code != 0
610
611 def test_prefix_dotdot_error_on_stderr(self, repo: pathlib.Path) -> None:
612 _make_commit(repo, {"a.py": b"a"})
613 result = runner.invoke(cli, ["archive", "--prefix", "../../etc"])
614 assert "❌" in result.stderr
615
616 def test_unknown_ref_does_not_glob(self, repo: pathlib.Path) -> None:
617 """A glob metacharacter in --ref must not trigger directory scanning."""
618 _make_commit(repo, {"a.py": b"a"})
619 result = runner.invoke(cli, ["archive", "--ref", "../../*"])
620 assert result.exit_code != 0
621
622 def test_tar_archive_has_no_traversal_paths(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
623 _make_commit(repo, {"safe/file.py": b"ok"})
624 out = tmp_path / "out.tar.gz"
625 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
626 with tarfile.open(out, "r:gz") as tar:
627 for name in tar.getnames():
628 assert not name.startswith("/")
629 assert ".." not in name.split("/")
630
631 def test_zip_archive_has_no_traversal_paths(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
632 _make_commit(repo, {"safe/file.py": b"ok"})
633 out = tmp_path / "out.zip"
634 runner.invoke(
635 cli, ["archive", "--format", "zip", "--output", str(out)],
636 catch_exceptions=False,
637 )
638 with zipfile.ZipFile(out) as zf:
639 for name in zf.namelist():
640 assert not name.startswith("/")
641 assert ".." not in name.split("/")
642
643
644 # ===========================================================================
645 # 5. Stress tests
646 # ===========================================================================
647
648
649 class TestStress:
650 def test_100_file_manifest_tar(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
651 files = {f"src/module_{i:03d}.py": f"# module {i}".encode() for i in range(100)}
652 _make_commit(repo, files)
653 out = tmp_path / "out.tar.gz"
654 result = runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
655 assert result.exit_code == 0
656 with tarfile.open(out, "r:gz") as tar:
657 assert len(tar.getnames()) == 100
658
659 def test_100_file_manifest_zip(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
660 files = {f"src/module_{i:03d}.py": f"# module {i}".encode() for i in range(100)}
661 _make_commit(repo, files)
662 out = tmp_path / "out.zip"
663 result = runner.invoke(
664 cli, ["archive", "--format", "zip", "--output", str(out)],
665 catch_exceptions=False,
666 )
667 assert result.exit_code == 0
668 with zipfile.ZipFile(out) as zf:
669 assert len(zf.namelist()) == 100
670
671 def test_list_mode_100_files(self, repo: pathlib.Path) -> None:
672 files = {f"f_{i:03d}.txt": b"x" for i in range(100)}
673 _make_commit(repo, files)
674 result = runner.invoke(cli, ["archive", "--list", "--json"], catch_exceptions=False)
675 data = json.loads(result.output)
676 assert data["file_count"] == 100
677 assert len(data["entries"]) == 100
678
679 def test_deeply_nested_paths(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
680 files = {"a/b/c/d/e/f/deep.py": b"deep"}
681 _make_commit(repo, files)
682 out = tmp_path / "out.tar.gz"
683 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
684 with tarfile.open(out, "r:gz") as tar:
685 assert "a/b/c/d/e/f/deep.py" in tar.getnames()
686
687 def test_large_file_content(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
688 big = b"x" * (1024 * 512) # 512 KiB
689 _make_commit(repo, {"big.bin": big})
690 out = tmp_path / "out.tar.gz"
691 result = runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
692 assert result.exit_code == 0
693 with tarfile.open(out, "r:gz") as tar:
694 f = tar.extractfile(tar.getmember("big.bin"))
695 assert f is not None
696 assert f.read() == big
697
698
699 # ===========================================================================
700 # 6. Performance tests
701 # ===========================================================================
702
703
704 class TestPerformance:
705 def test_single_file_archive_under_500ms(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
706 _make_commit(repo, {"a.py": b"a"})
707 out = tmp_path / "out.tar.gz"
708 start = time.monotonic()
709 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
710 elapsed = time.monotonic() - start
711 assert elapsed < 0.5, f"single-file archive took {elapsed:.3f}s"
712
713 def test_list_mode_under_300ms(self, repo: pathlib.Path) -> None:
714 files = {f"f_{i}.py": b"x" for i in range(20)}
715 _make_commit(repo, files)
716 start = time.monotonic()
717 runner.invoke(cli, ["archive", "--list", "--json"], catch_exceptions=False)
718 elapsed = time.monotonic() - start
719 assert elapsed < 0.3, f"list mode took {elapsed:.3f}s"
720
721 def test_json_output_under_500ms(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
722 files = {f"f_{i}.py": b"x" for i in range(10)}
723 _make_commit(repo, files)
724 out = tmp_path / "out.tar.gz"
725 start = time.monotonic()
726 runner.invoke(cli, ["archive", "--json", "--output", str(out)], catch_exceptions=False)
727 elapsed = time.monotonic() - start
728 assert elapsed < 0.5, f"json archive took {elapsed:.3f}s"
729
730
731 # ===========================================================================
732 # 7. Data Integrity tests
733 # ===========================================================================
734
735
736 class TestDataIntegrity:
737 def test_archive_contains_exactly_manifest_files(
738 self, repo: pathlib.Path, tmp_path: pathlib.Path
739 ) -> None:
740 """Every file in the snapshot manifest appears in the archive, no more."""
741 files = {"a.py": b"a", "b/c.py": b"bc", "d.txt": b"d"}
742 _make_commit(repo, files)
743 out = tmp_path / "out.tar.gz"
744 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
745 with tarfile.open(out, "r:gz") as tar:
746 names = set(tar.getnames())
747 assert names == set(files.keys())
748
749 def test_file_bytes_match_original(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
750 content = b"\x00\x01\x02binary\xff\xfe"
751 _make_commit(repo, {"binary.bin": content})
752 out = tmp_path / "out.tar.gz"
753 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
754 with tarfile.open(out, "r:gz") as tar:
755 f = tar.extractfile(tar.getmember("binary.bin"))
756 assert f is not None
757 assert f.read() == content
758
759 def test_zip_bytes_match_original(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
760 content = b"exact content"
761 _make_commit(repo, {"f.txt": content})
762 out = tmp_path / "out.zip"
763 runner.invoke(
764 cli, ["archive", "--format", "zip", "--output", str(out)],
765 catch_exceptions=False,
766 )
767 with zipfile.ZipFile(out) as zf:
768 assert zf.read("f.txt") == content
769
770 def test_list_entries_match_archive_entries(
771 self, repo: pathlib.Path, tmp_path: pathlib.Path
772 ) -> None:
773 """Files listed by --list match files written to the archive."""
774 files = {"x.py": b"x", "y/z.py": b"yz"}
775 _make_commit(repo, files)
776 list_result = runner.invoke(
777 cli, ["archive", "--list", "--json"], catch_exceptions=False
778 )
779 list_data = json.loads(list_result.output)
780 listed_paths = {e["path"] for e in list_data["entries"]}
781
782 out = tmp_path / "out.tar.gz"
783 runner.invoke(cli, ["archive", "--output", str(out)], catch_exceptions=False)
784 with tarfile.open(out, "r:gz") as tar:
785 archive_paths = set(tar.getnames())
786
787 assert listed_paths == archive_paths
788
789 def test_list_entries_sorted(self, repo: pathlib.Path) -> None:
790 files = {"z.py": b"z", "a.py": b"a", "m.py": b"m"}
791 _make_commit(repo, files)
792 result = runner.invoke(
793 cli, ["archive", "--list", "--json"], catch_exceptions=False
794 )
795 data = json.loads(result.output)
796 paths = [e["path"] for e in data["entries"]]
797 assert paths == sorted(paths)
798
799 def test_committed_at_iso8601(self, repo: pathlib.Path, tmp_path: pathlib.Path) -> None:
800 _make_commit(repo, {"a.py": b"a"})
801 out = tmp_path / "out.tar.gz"
802 result = runner.invoke(
803 cli, ["archive", "--json", "--output", str(out)], catch_exceptions=False
804 )
805 data = json.loads(result.output)
806 # Must parse without error
807 dt = datetime.datetime.fromisoformat(data["committed_at"])
808 assert dt.tzinfo is not None
809
810 def test_json_path_field_matches_written_file(
811 self, repo: pathlib.Path, tmp_path: pathlib.Path
812 ) -> None:
813 _make_commit(repo, {"a.py": b"a"})
814 out = tmp_path / "exact-name.tar.gz"
815 result = runner.invoke(
816 cli, ["archive", "--json", "--output", str(out)], catch_exceptions=False
817 )
818 data = json.loads(result.output)
819 assert pathlib.Path(data["path"]) == out
820
821 def test_json_bytes_matches_file_size(
822 self, repo: pathlib.Path, tmp_path: pathlib.Path
823 ) -> None:
824 _make_commit(repo, {"a.py": b"content here"})
825 out = tmp_path / "out.tar.gz"
826 result = runner.invoke(
827 cli, ["archive", "--json", "--output", str(out)], catch_exceptions=False
828 )
829 data = json.loads(result.output)
830 assert data["bytes"] == out.stat().st_size
831
832 def test_format_choices_complete(self) -> None:
833 assert _FORMAT_CHOICES == {"tar.gz", "zip"}
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago