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