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