gabriel / muse public
test_cmd_archive.py python
242 lines 9.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive tests for ``muse archive``.
2
3 Covers:
4 - Unit: _safe_arcname zip-slip guard
5 - Integration: archive a commit to tar.gz and zip
6 - E2E: full CLI via CliRunner with output path
7 - Security: --prefix validation, zip-slip prevention in manifest paths
8 - Stress: archive with many tracked files
9 """
10
11 from __future__ import annotations
12
13 type _FileStore = dict[str, bytes]
14
15 import datetime
16 import hashlib
17 import json
18 import pathlib
19 import tarfile
20 import uuid
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 runner = CliRunner()
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35 def _env(root: pathlib.Path) -> Manifest:
36 return {"MUSE_REPO_ROOT": str(root)}
37
38
39 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
40 muse_dir = tmp_path / ".muse"
41 muse_dir.mkdir()
42 repo_id = str(uuid.uuid4())
43 (muse_dir / "repo.json").write_text(json.dumps({
44 "repo_id": repo_id,
45 "domain": "midi",
46 "default_branch": "main",
47 "created_at": "2025-01-01T00:00:00+00:00",
48 }), encoding="utf-8")
49 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (muse_dir / "refs" / "heads").mkdir(parents=True)
51 (muse_dir / "snapshots").mkdir()
52 (muse_dir / "commits").mkdir()
53 (muse_dir / "objects").mkdir()
54 return tmp_path, repo_id
55
56
57 def _make_commit_with_files(
58 root: pathlib.Path, repo_id: str, files: _FileStore | None = None
59 ) -> str:
60 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
61 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
62
63 ref_file = root / ".muse" / "refs" / "heads" / "main"
64 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
65
66 manifest: Manifest = {}
67 if files:
68 for rel_path, content in files.items():
69 obj_id = hashlib.sha256(content).hexdigest()
70 obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
71 obj_path.parent.mkdir(parents=True, exist_ok=True)
72 obj_path.write_bytes(content)
73 manifest[rel_path] = obj_id
74
75 snap_id = compute_snapshot_id(manifest)
76 committed_at = datetime.datetime.now(datetime.timezone.utc)
77 commit_id = compute_commit_id(
78 parent_ids=[parent_id] if parent_id else [],
79 snapshot_id=snap_id, message="archive test",
80 committed_at_iso=committed_at.isoformat(),
81 )
82 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
83 write_commit(root, CommitRecord(
84 commit_id=commit_id, repo_id=repo_id, branch="main",
85 snapshot_id=snap_id, message="archive test",
86 committed_at=committed_at, parent_commit_id=parent_id,
87 ))
88 ref_file.parent.mkdir(parents=True, exist_ok=True)
89 ref_file.write_text(commit_id, encoding="utf-8")
90 return commit_id
91
92
93 # ---------------------------------------------------------------------------
94 # Unit tests
95 # ---------------------------------------------------------------------------
96
97 class TestArchiveUnit:
98 def test_safe_arcname_normal_path(self) -> None:
99 from muse.cli.commands.archive import _safe_arcname
100 assert _safe_arcname("myproject", "state/song.mid") == "myproject/state/song.mid"
101
102 def test_safe_arcname_no_prefix(self) -> None:
103 from muse.cli.commands.archive import _safe_arcname
104 assert _safe_arcname("", "state/song.mid") == "state/song.mid"
105
106 def test_safe_arcname_traversal_in_rel_path_rejected(self) -> None:
107 from muse.cli.commands.archive import _safe_arcname
108 assert _safe_arcname("prefix", "../../../etc/passwd") is None
109
110 def test_safe_arcname_absolute_rel_path_rejected(self) -> None:
111 from muse.cli.commands.archive import _safe_arcname
112 assert _safe_arcname("prefix", "/etc/passwd") is None
113
114 def test_safe_arcname_traversal_in_prefix_rejected(self) -> None:
115 from muse.cli.commands.archive import _safe_arcname
116 assert _safe_arcname("../evil", "file.txt") is None
117
118 def test_safe_arcname_trailing_slash_normalised(self) -> None:
119 from muse.cli.commands.archive import _safe_arcname
120 assert _safe_arcname("myproject/", "file.txt") == "myproject/file.txt"
121
122
123 # ---------------------------------------------------------------------------
124 # Integration tests
125 # ---------------------------------------------------------------------------
126
127 class TestArchiveIntegration:
128 def test_archive_empty_commit(self, tmp_path: pathlib.Path) -> None:
129 root, repo_id = _init_repo(tmp_path)
130 _make_commit_with_files(root, repo_id, files={})
131 out = tmp_path / "out.tar.gz"
132 result = runner.invoke(cli, ["archive", "--output", str(out)], env=_env(root), catch_exceptions=False)
133 assert result.exit_code == 0
134 assert out.exists()
135
136 def test_archive_tar_gz_contains_files(self, tmp_path: pathlib.Path) -> None:
137 root, repo_id = _init_repo(tmp_path)
138 _make_commit_with_files(root, repo_id, files={"state/song.mid": b"\x00\x00MIDI"})
139 out = tmp_path / "archive.tar.gz"
140 result = runner.invoke(cli, ["archive", "--output", str(out)], env=_env(root), catch_exceptions=False)
141 assert result.exit_code == 0
142 with tarfile.open(out, "r:gz") as tf:
143 names = tf.getnames()
144 assert any("song.mid" in n for n in names)
145
146 def test_archive_zip_contains_files(self, tmp_path: pathlib.Path) -> None:
147 root, repo_id = _init_repo(tmp_path)
148 _make_commit_with_files(root, repo_id, files={"track.mid": b"MIDIdata"})
149 out = tmp_path / "archive.zip"
150 result = runner.invoke(
151 cli, ["archive", "--format", "zip", "--output", str(out)],
152 env=_env(root), catch_exceptions=False,
153 )
154 assert result.exit_code == 0
155 with zipfile.ZipFile(out, "r") as zf:
156 names = zf.namelist()
157 assert any("track.mid" in n for n in names)
158
159 def test_archive_with_prefix(self, tmp_path: pathlib.Path) -> None:
160 root, repo_id = _init_repo(tmp_path)
161 _make_commit_with_files(root, repo_id, files={"song.mid": b"data"})
162 out = tmp_path / "prefixed.tar.gz"
163 result = runner.invoke(
164 cli, ["archive", "--output", str(out), "--prefix", "myband-v1.0/"],
165 env=_env(root), catch_exceptions=False,
166 )
167 assert result.exit_code == 0
168 with tarfile.open(out, "r:gz") as tf:
169 names = tf.getnames()
170 assert any("myband-v1.0" in n for n in names)
171
172 def test_archive_unknown_format_fails(self, tmp_path: pathlib.Path) -> None:
173 root, repo_id = _init_repo(tmp_path)
174 _make_commit_with_files(root, repo_id)
175 result = runner.invoke(cli, ["archive", "--format", "rar"], env=_env(root))
176 assert result.exit_code != 0
177
178 def test_archive_no_commits_fails(self, tmp_path: pathlib.Path) -> None:
179 root, repo_id = _init_repo(tmp_path)
180 result = runner.invoke(cli, ["archive"], env=_env(root))
181 assert result.exit_code != 0
182
183 def test_archive_short_flags(self, tmp_path: pathlib.Path) -> None:
184 root, repo_id = _init_repo(tmp_path)
185 _make_commit_with_files(root, repo_id, files={"test.mid": b"data"})
186 out = tmp_path / "short.tar.gz"
187 result = runner.invoke(
188 cli, ["archive", "-f", "tar.gz", "-o", str(out)],
189 env=_env(root), catch_exceptions=False,
190 )
191 assert result.exit_code == 0
192
193
194 # ---------------------------------------------------------------------------
195 # Security tests
196 # ---------------------------------------------------------------------------
197
198 class TestArchiveSecurity:
199 def test_prefix_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
200 root, repo_id = _init_repo(tmp_path)
201 _make_commit_with_files(root, repo_id, files={"song.mid": b"data"})
202 out = tmp_path / "evil.tar.gz"
203 result = runner.invoke(
204 cli, ["archive", "--output", str(out), "--prefix", "../evil/"],
205 env=_env(root),
206 )
207 assert result.exit_code != 0
208
209 def test_zip_slip_manifest_path_skipped(self, tmp_path: pathlib.Path) -> None:
210 """A manifest entry with '../' is skipped, not written to archive."""
211 root, repo_id = _init_repo(tmp_path)
212 from muse.cli.commands.archive import _build_tar
213 content = b"evil content"
214 obj_id = hashlib.sha256(content).hexdigest()
215 obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
216 obj_path.parent.mkdir(parents=True, exist_ok=True)
217 obj_path.write_bytes(content)
218
219 out = tmp_path / "safe.tar.gz"
220 manifest = {"../../../etc/passwd": obj_id, "safe.txt": obj_id}
221 count = _build_tar(root, manifest, out, prefix="")
222 assert count == 1 # only safe.txt
223 with tarfile.open(out, "r:gz") as tf:
224 names = tf.getnames()
225 assert all("etc" not in n for n in names)
226
227
228 # ---------------------------------------------------------------------------
229 # Stress tests
230 # ---------------------------------------------------------------------------
231
232 class TestArchiveStress:
233 def test_archive_many_files(self, tmp_path: pathlib.Path) -> None:
234 root, repo_id = _init_repo(tmp_path)
235 files = {f"track_{i:03d}.mid": f"MIDI{i}".encode() for i in range(50)}
236 _make_commit_with_files(root, repo_id, files=files)
237 out = tmp_path / "many.tar.gz"
238 result = runner.invoke(cli, ["archive", "--output", str(out)], env=_env(root), catch_exceptions=False)
239 assert result.exit_code == 0
240 with tarfile.open(out, "r:gz") as tf:
241 names = tf.getnames()
242 assert len(names) == 50
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago