gabriel / muse public
test_cmd_hash_object.py python
403 lines 16.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Comprehensive tests for ``muse hash-object``.
2
3 Coverage tiers
4 --------------
5 - Unit: _hash_bytes correctness, _emit output shape
6 - Integration: all flags, stdin mode, --write lifecycle, idempotency
7 - Security: ANSI injection in path errors, path traversal attempt
8 - Stress: large file (streaming), 500 sequential hashes, binary content
9 """
10 from __future__ import annotations
11
12 import hashlib
13 import json
14 import pathlib
15
16 import pytest
17
18 from muse.core.errors import ExitCode
19 from tests.cli_test_helper import CliRunner, InvokeResult
20 from muse.core._types import blob_id, long_id
21 from muse.core.object_store import object_path
22
23 runner = CliRunner()
24
25 # ---------------------------------------------------------------------------
26 # Helpers shared across tests
27 # ---------------------------------------------------------------------------
28
29 def _plumb(tmp_path: pathlib.Path, *args: str, stdin: bytes | None = None) -> InvokeResult:
30 from muse.cli.app import main as cli
31 return runner.invoke(cli, ["hash-object", *args], input=stdin)
32
33
34 def _plumb_repo(repo: pathlib.Path, *args: str, stdin: bytes | None = None) -> InvokeResult:
35 from muse.cli.app import main as cli
36 return runner.invoke(
37 cli,
38 ["hash-object", *args],
39 env={"MUSE_REPO_ROOT": str(repo)},
40 input=stdin,
41 )
42
43
44 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
45 """Minimal .muse/ structure."""
46 repo = tmp_path / "repo"
47 muse = repo / ".muse"
48 for sub in ("objects", "commits", "snapshots", "refs/heads"):
49 (muse / sub).mkdir(parents=True)
50 (muse / "HEAD").write_text("ref: refs/heads/main")
51 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
52 return repo
53
54
55 # ---------------------------------------------------------------------------
56 # Unit — _hash_bytes
57 # ---------------------------------------------------------------------------
58
59
60 class TestHashBytes:
61 def test_known_sha256_empty(self) -> None:
62 from muse.cli.commands.hash_object import _hash_bytes
63 assert _hash_bytes(b"") == blob_id(b"")
64
65 def test_known_sha256_hello_world(self) -> None:
66 from muse.cli.commands.hash_object import _hash_bytes
67 expected = long_id(hashlib.sha256(b"hello world").hexdigest())
68 assert _hash_bytes(b"hello world") == expected
69
70 def test_deterministic(self) -> None:
71 from muse.cli.commands.hash_object import _hash_bytes
72 data = b"some content " * 100
73 assert _hash_bytes(data) == _hash_bytes(data)
74
75 def test_different_content_different_hash(self) -> None:
76 from muse.cli.commands.hash_object import _hash_bytes
77 assert _hash_bytes(b"a") != _hash_bytes(b"b")
78
79 def test_returns_canonical_prefixed_id(self) -> None:
80 from muse.cli.commands.hash_object import _hash_bytes
81 result = _hash_bytes(b"test")
82 assert result.startswith("sha256:")
83 assert len(result) == 71 # sha256: (7) + 64 hex chars
84 assert all(c in "0123456789abcdef" for c in result[len("sha256:"):])
85
86
87 class TestEmit:
88 def test_text_format_prints_hash(self, capsys: pytest.CaptureFixture[str]) -> None:
89 from muse.cli.commands.hash_object import _emit
90 from muse.core.timing import start_timer
91 oid = long_id("a" * 64)
92 _emit("text", oid, False, 0, start_timer())
93 out = capsys.readouterr().out.strip()
94 assert out == oid
95
96 def test_json_format_has_fields(self, capsys: pytest.CaptureFixture[str]) -> None:
97 from muse.cli.commands.hash_object import _emit
98 from muse.core.timing import start_timer
99 oid = long_id("b" * 64)
100 _emit("json", oid, True, 42, start_timer())
101 data = json.loads(capsys.readouterr().out)
102 assert data["object_id"] == oid
103 assert data["stored"] is True
104 assert data["size_bytes"] == 42
105 assert "duration_ms" in data
106 assert "exit_code" in data
107
108
109 # ---------------------------------------------------------------------------
110 # Integration — file mode
111 # ---------------------------------------------------------------------------
112
113
114 class TestFileMode:
115 def test_json_output_shape(self, tmp_path: pathlib.Path) -> None:
116 f = tmp_path / "data.txt"
117 f.write_bytes(b"hello world")
118 result = _plumb(tmp_path, str(f))
119 assert result.exit_code == 0
120 data = json.loads(result.output)
121 assert "object_id" in data
122 assert "stored" in data
123 assert data["object_id"].startswith("sha256:")
124 assert len(data["object_id"]) == 71
125 assert data["stored"] is False
126
127 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
128 f = tmp_path / "data.txt"
129 f.write_bytes(b"content")
130 result = _plumb(tmp_path, "--json", str(f))
131 assert result.exit_code == 0
132 data = json.loads(result.output)
133 assert "object_id" in data
134
135 def test_text_format_is_canonical_id(self, tmp_path: pathlib.Path) -> None:
136 f = tmp_path / "data.txt"
137 f.write_bytes(b"test bytes")
138 result = _plumb(tmp_path, "--format", "text", str(f))
139 assert result.exit_code == 0
140 raw = result.output.strip()
141 assert raw.startswith("sha256:")
142 assert len(raw) == 71
143
144 def test_text_and_json_same_hash(self, tmp_path: pathlib.Path) -> None:
145 f = tmp_path / "same.txt"
146 f.write_bytes(b"identical content")
147 json_result = _plumb(tmp_path, "--format", "json", str(f))
148 text_result = _plumb(tmp_path, "--format", "text", str(f))
149 json_id = json.loads(json_result.output)["object_id"]
150 text_id = text_result.output.strip()
151 assert json_id == text_id
152
153 def test_determinism_same_content_same_hash(self, tmp_path: pathlib.Path) -> None:
154 f1 = tmp_path / "f1.txt"
155 f2 = tmp_path / "f2.txt"
156 f1.write_bytes(b"same bytes")
157 f2.write_bytes(b"same bytes")
158 r1 = json.loads(_plumb(tmp_path, str(f1)).output)["object_id"]
159 r2 = json.loads(_plumb(tmp_path, str(f2)).output)["object_id"]
160 assert r1 == r2
161
162 def test_different_content_different_hash(self, tmp_path: pathlib.Path) -> None:
163 f1 = tmp_path / "f1.txt"
164 f2 = tmp_path / "f2.txt"
165 f1.write_bytes(b"alpha")
166 f2.write_bytes(b"beta")
167 r1 = json.loads(_plumb(tmp_path, str(f1)).output)["object_id"]
168 r2 = json.loads(_plumb(tmp_path, str(f2)).output)["object_id"]
169 assert r1 != r2
170
171 def test_empty_file(self, tmp_path: pathlib.Path) -> None:
172 f = tmp_path / "empty.txt"
173 f.write_bytes(b"")
174 result = _plumb(tmp_path, str(f))
175 assert result.exit_code == 0
176 data = json.loads(result.output)
177 assert data["object_id"] == long_id(hashlib.sha256(b"").hexdigest())
178
179 def test_binary_content(self, tmp_path: pathlib.Path) -> None:
180 f = tmp_path / "binary.bin"
181 f.write_bytes(bytes(range(256)) * 10)
182 result = _plumb(tmp_path, str(f))
183 assert result.exit_code == 0
184 data = json.loads(result.output)
185 assert data["object_id"].startswith("sha256:")
186 assert len(data["object_id"]) == 71
187
188 def test_invalid_format_errors(self, tmp_path: pathlib.Path) -> None:
189 f = tmp_path / "data.txt"
190 f.write_bytes(b"x")
191 result = _plumb(tmp_path, "--format", "xml", str(f))
192 assert result.exit_code == ExitCode.USER_ERROR
193
194 def test_missing_file_errors(self, tmp_path: pathlib.Path) -> None:
195 result = _plumb(tmp_path, str(tmp_path / "nonexistent.txt"))
196 assert result.exit_code == ExitCode.USER_ERROR
197
198 def test_directory_as_path_errors(self, tmp_path: pathlib.Path) -> None:
199 result = _plumb(tmp_path, str(tmp_path))
200 assert result.exit_code == ExitCode.USER_ERROR
201
202 def test_no_args_errors(self, tmp_path: pathlib.Path) -> None:
203 result = _plumb(tmp_path)
204 assert result.exit_code != 0
205
206
207 # ---------------------------------------------------------------------------
208 # Integration — --write lifecycle
209 # ---------------------------------------------------------------------------
210
211
212 class TestWrite:
213 def test_write_returns_stored_true(self, tmp_path: pathlib.Path) -> None:
214 repo = _make_repo(tmp_path)
215 f = repo / "sample.txt"
216 f.write_bytes(b"store me")
217 result = _plumb_repo(repo, "--write", str(f))
218 assert result.exit_code == 0
219 assert json.loads(result.output)["stored"] is True
220
221 def test_write_creates_object_file(self, tmp_path: pathlib.Path) -> None:
222 repo = _make_repo(tmp_path)
223 f = repo / "sample.txt"
224 content = b"store me too"
225 f.write_bytes(content)
226 result = _plumb_repo(repo, "--write", str(f))
227 data = json.loads(result.output)
228 oid = data["object_id"]
229 obj_file = object_path(repo, oid)
230 assert obj_file.exists()
231 assert obj_file.read_bytes() == content
232
233 def test_write_idempotent_second_call_stored_false(self, tmp_path: pathlib.Path) -> None:
234 repo = _make_repo(tmp_path)
235 f = repo / "dup.txt"
236 f.write_bytes(b"duplicate content")
237 _plumb_repo(repo, "--write", str(f))
238 result2 = _plumb_repo(repo, "--write", str(f))
239 assert result2.exit_code == 0
240 assert json.loads(result2.output)["stored"] is False
241
242 def test_write_without_repo_errors(self, tmp_path: pathlib.Path) -> None:
243 f = tmp_path / "orphan.txt"
244 f.write_bytes(b"no repo")
245 # Point MUSE_REPO_ROOT at a dir with no .muse/ to force find_repo_root → None
246 result = runner.invoke(
247 __import__("muse.cli.app", fromlist=["main"]).main,
248 ["hash-object", "--write", str(f)],
249 env={"MUSE_REPO_ROOT": str(tmp_path / "no_repo_here")},
250 )
251 assert result.exit_code == ExitCode.USER_ERROR
252
253 def test_write_text_format_still_works(self, tmp_path: pathlib.Path) -> None:
254 repo = _make_repo(tmp_path)
255 f = repo / "text.txt"
256 f.write_bytes(b"text mode write")
257 result = _plumb_repo(repo, "--write", "--format", "text", str(f))
258 assert result.exit_code == 0
259 raw = result.output.strip()
260 assert raw.startswith("sha256:")
261 assert len(raw) == 71
262
263
264 # ---------------------------------------------------------------------------
265 # Integration — --stdin mode
266 # ---------------------------------------------------------------------------
267
268
269 class TestStdinMode:
270 def test_stdin_produces_correct_hash(self, tmp_path: pathlib.Path) -> None:
271 content = b"piped content"
272 result = _plumb(tmp_path, "--stdin", stdin=content)
273 assert result.exit_code == 0
274 data = json.loads(result.output)
275 assert data["object_id"] == long_id(hashlib.sha256(content).hexdigest())
276 assert data["stored"] is False
277
278 def test_stdin_matches_file_hash(self, tmp_path: pathlib.Path) -> None:
279 content = b"same content"
280 f = tmp_path / "f.txt"
281 f.write_bytes(content)
282 file_result = json.loads(_plumb(tmp_path, str(f)).output)["object_id"]
283 stdin_result = json.loads(_plumb(tmp_path, "--stdin", stdin=content).output)["object_id"]
284 assert file_result == stdin_result
285
286 def test_stdin_text_format(self, tmp_path: pathlib.Path) -> None:
287 content = b"text stdin"
288 result = _plumb(tmp_path, "--stdin", "--format", "text", stdin=content)
289 assert result.exit_code == 0
290 assert result.output.strip() == long_id(hashlib.sha256(content).hexdigest())
291
292 def test_stdin_empty_input(self, tmp_path: pathlib.Path) -> None:
293 result = _plumb(tmp_path, "--stdin", stdin=b"")
294 assert result.exit_code == 0
295 data = json.loads(result.output)
296 assert data["object_id"] == long_id(hashlib.sha256(b"").hexdigest())
297
298 def test_stdin_and_path_mutually_exclusive(self, tmp_path: pathlib.Path) -> None:
299 f = tmp_path / "f.txt"
300 f.write_bytes(b"x")
301 result = _plumb(tmp_path, "--stdin", str(f))
302 assert result.exit_code == ExitCode.USER_ERROR
303
304 def test_stdin_write_stores_object(self, tmp_path: pathlib.Path) -> None:
305 repo = _make_repo(tmp_path)
306 content = b"stdin stored"
307 result = _plumb_repo(repo, "--stdin", "--write", stdin=content)
308 assert result.exit_code == 0
309 data = json.loads(result.output)
310 assert data["stored"] is True
311 oid = data["object_id"]
312 obj_file = object_path(repo, oid)
313 assert obj_file.exists()
314
315 def test_stdin_write_without_repo_errors(self, tmp_path: pathlib.Path) -> None:
316 from muse.cli.app import main as cli
317 result = runner.invoke(
318 cli,
319 ["hash-object", "--stdin", "--write"],
320 env={"MUSE_REPO_ROOT": str(tmp_path / "no_repo_here")},
321 input=b"no repo",
322 )
323 assert result.exit_code == ExitCode.USER_ERROR
324
325
326 # ---------------------------------------------------------------------------
327 # Security
328 # ---------------------------------------------------------------------------
329
330
331 class TestSecurity:
332 def test_ansi_in_path_not_in_stderr(self, tmp_path: pathlib.Path) -> None:
333 """A path with embedded ANSI escapes must not reach stderr output."""
334 evil_name = tmp_path / "\x1b[31mevil\x1b[0m.txt"
335 result = _plumb(tmp_path, str(evil_name))
336 assert result.exit_code != 0
337 assert "\x1b" not in result.output
338
339 def test_path_traversal_attempt_outside_repo(self, tmp_path: pathlib.Path) -> None:
340 """/../ in a path is just a filesystem lookup — it either exists or doesn't."""
341 traversal = tmp_path / ".." / "etc" / "passwd"
342 result = _plumb(tmp_path, str(traversal))
343 # If the file doesn't exist, we get USER_ERROR cleanly — not a crash.
344 assert result.exit_code in (0, ExitCode.USER_ERROR)
345
346 def test_no_path_no_stdin_clean_error(self, tmp_path: pathlib.Path) -> None:
347 result = _plumb(tmp_path)
348 assert result.exit_code != 0
349 # Must not be a Python traceback
350 assert "Traceback" not in result.output
351
352 def test_json_output_is_never_a_traceback(self, tmp_path: pathlib.Path) -> None:
353 """Even on error, output must be parseable or stderr-only."""
354 result = _plumb(tmp_path, str(tmp_path / "missing.txt"))
355 assert result.exit_code != 0
356 # stdout should be empty (error went to stderr)
357 assert result.output.strip() == "" or "Traceback" not in result.output
358
359
360 # ---------------------------------------------------------------------------
361 # Stress
362 # ---------------------------------------------------------------------------
363
364
365 class TestStress:
366 def test_large_file_streams_without_oom(self, tmp_path: pathlib.Path) -> None:
367 """A 10 MiB file must hash without loading the full content into memory."""
368 large = tmp_path / "large.bin"
369 chunk = b"X" * 65536 # 64 KiB chunk
370 with large.open("wb") as fh:
371 for _ in range(160): # 160 × 64 KiB = 10 MiB
372 fh.write(chunk)
373 result = _plumb(tmp_path, str(large))
374 assert result.exit_code == 0
375 data = json.loads(result.output)
376 assert data["object_id"].startswith("sha256:")
377 assert len(data["object_id"]) == 71
378
379 def test_large_file_hash_matches_reference(self, tmp_path: pathlib.Path) -> None:
380 """Chunked hash_file must match a one-shot hashlib computation."""
381 large = tmp_path / "ref.bin"
382 content = bytes(range(256)) * 4096 # 1 MiB, non-repeating byte pattern
383 large.write_bytes(content)
384 result = _plumb(tmp_path, str(large))
385 expected = long_id(hashlib.sha256(content).hexdigest())
386 assert json.loads(result.output)["object_id"] == expected
387
388 def test_500_sequential_hashes(self, tmp_path: pathlib.Path) -> None:
389 """500 rapid hash calls must all succeed with consistent results."""
390 f = tmp_path / "stable.txt"
391 f.write_bytes(b"stable content")
392 expected = long_id(hashlib.sha256(b"stable content").hexdigest())
393 for i in range(500):
394 result = _plumb(tmp_path, str(f))
395 assert result.exit_code == 0, f"failed at iteration {i}"
396 assert json.loads(result.output)["object_id"] == expected
397
398 def test_stdin_large_binary(self, tmp_path: pathlib.Path) -> None:
399 """Stdin mode handles 1 MiB of binary content correctly."""
400 content = bytes(range(256)) * 4096
401 result = _plumb(tmp_path, "--stdin", stdin=content)
402 assert result.exit_code == 0
403 assert json.loads(result.output)["object_id"] == long_id(hashlib.sha256(content).hexdigest())
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 145 days ago