gabriel / muse public
test_cmd_verify_object.py python
792 lines 32.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Comprehensive tests for ``muse verify-object``.
2
3 Coverage tiers
4 --------------
5 - Unit: _iter_all_object_ids, _verify_one (all paths), schema, constants
6 - Integration: JSON/text/quiet, --all, --stdin, --fail-fast, ordering, counts
7 - Data integrity: truncated file, zero-byte blob, large-object streaming
8 - Security: stderr routing, ANSI stripping, path traversal, unicode, CRLF,
9 symlink shard directory
10 - Stress: 100-object --all, 1000-object --all, 200 sequential verifies,
11 stdin 200 ids, duration bounded for small ops
12 """
13 from __future__ import annotations
14
15 import json
16 import os
17 import pathlib
18
19 import pytest
20
21 from muse.core._types import blob_id, fake_id
22 from muse.core.errors import ExitCode
23 from muse.core.object_store import object_path, write_object
24 from tests.cli_test_helper import CliRunner, InvokeResult
25
26 runner = CliRunner()
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32 _FAKE_CONTENT = b"hello muse"
33 _GOOD_OID = blob_id(_FAKE_CONTENT)
34
35
36 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
37 repo = tmp_path / "repo"
38 muse = repo / ".muse"
39 (muse / "objects").mkdir(parents=True)
40 (muse / "commits").mkdir(parents=True)
41 (muse / "snapshots").mkdir(parents=True)
42 (muse / "refs" / "heads").mkdir(parents=True)
43 (muse / "HEAD").write_text("ref: refs/heads/main")
44 (muse / "repo.json").write_text(json.dumps({"repo_id": "r1", "domain": "code"}))
45 return repo
46
47
48 def _write_object(repo: pathlib.Path, content: bytes) -> str:
49 """Write real content into the store and return its sha256:-prefixed ID."""
50 oid = blob_id(content)
51 write_object(repo, oid, content)
52 return oid
53
54
55 def _corrupt_object(repo: pathlib.Path, oid: str) -> None:
56 """Overwrite the object file with garbage (simulates bit-rot).
57
58 The object store writes files as 0o444 (read-only) to enforce immutability.
59 We must make the file writable before overwriting it in tests.
60 """
61 obj_file = object_path(repo, oid)
62 os.chmod(obj_file, 0o644)
63 obj_file.write_bytes(b"corrupted data that does not hash to the oid")
64
65
66 def _truncate_object(repo: pathlib.Path, oid: str, keep_bytes: int = 0) -> None:
67 """Truncate the object file to ``keep_bytes`` bytes."""
68 obj_file = object_path(repo, oid)
69 os.chmod(obj_file, 0o644)
70 data = obj_file.read_bytes()
71 obj_file.write_bytes(data[:keep_bytes])
72
73
74 def _vo(repo: pathlib.Path, *args: str, stdin: str | None = None) -> InvokeResult:
75 from muse.cli.app import main as cli
76 return runner.invoke(
77 cli,
78 ["verify-object", *args],
79 env={"MUSE_REPO_ROOT": str(repo)},
80 input=stdin,
81 )
82
83
84 # ---------------------------------------------------------------------------
85 # Unit — _iter_all_object_ids
86 # ---------------------------------------------------------------------------
87
88
89 class TestIterAllObjectIds:
90 def test_empty_store(self, tmp_path: pathlib.Path) -> None:
91 from muse.cli.commands.verify_object import _iter_all_object_ids
92 repo = _make_repo(tmp_path)
93 assert _iter_all_object_ids(repo) == []
94
95 def test_missing_objects_dir(self, tmp_path: pathlib.Path) -> None:
96 from muse.cli.commands.verify_object import _iter_all_object_ids
97 import shutil
98 repo = _make_repo(tmp_path)
99 shutil.rmtree(repo / ".muse" / "objects")
100 assert _iter_all_object_ids(repo) == []
101
102 def test_finds_written_object(self, tmp_path: pathlib.Path) -> None:
103 from muse.cli.commands.verify_object import _iter_all_object_ids
104 repo = _make_repo(tmp_path)
105 oid = _write_object(repo, b"test content")
106 assert oid in _iter_all_object_ids(repo)
107
108 def test_multiple_objects_sorted(self, tmp_path: pathlib.Path) -> None:
109 from muse.cli.commands.verify_object import _iter_all_object_ids
110 repo = _make_repo(tmp_path)
111 oids = [_write_object(repo, f"content {i}".encode()) for i in range(5)]
112 found = _iter_all_object_ids(repo)
113 assert set(oids) == set(found)
114 assert found == sorted(found)
115
116 def test_symlinks_in_shard_skipped(self, tmp_path: pathlib.Path) -> None:
117 from muse.cli.commands.verify_object import _iter_all_object_ids
118 repo = _make_repo(tmp_path)
119 oid = _write_object(repo, b"real content")
120 shard = object_path(repo, oid).parent
121 sym = shard / "symlink_file"
122 sym.symlink_to(object_path(repo, oid))
123 ids = _iter_all_object_ids(repo)
124 assert ids.count(oid) == 1
125
126 def test_short_shard_dir_names_ignored(self, tmp_path: pathlib.Path) -> None:
127 from muse.cli.commands.verify_object import _iter_all_object_ids
128 from muse.core.object_store import objects_dir
129 repo = _make_repo(tmp_path)
130 (objects_dir(repo) / "sha256" / "abc").mkdir(parents=True, exist_ok=True)
131 assert _iter_all_object_ids(repo) == []
132
133 def test_returns_sha256_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
134 from muse.cli.commands.verify_object import _iter_all_object_ids
135 repo = _make_repo(tmp_path)
136 _write_object(repo, b"prefix check")
137 ids = _iter_all_object_ids(repo)
138 assert all(oid.startswith("sha256:") for oid in ids)
139
140
141 # ---------------------------------------------------------------------------
142 # Unit — _verify_one
143 # ---------------------------------------------------------------------------
144
145
146 class TestVerifyOne:
147 def test_valid_object_ok(self, tmp_path: pathlib.Path) -> None:
148 from muse.cli.commands.verify_object import _verify_one
149 repo = _make_repo(tmp_path)
150 oid = _write_object(repo, b"hello world")
151 result = _verify_one(repo, oid)
152 assert result["ok"] is True
153 assert result["size_bytes"] == len(b"hello world")
154 assert result["error"] is None
155
156 def test_ok_result_preserves_object_id(self, tmp_path: pathlib.Path) -> None:
157 from muse.cli.commands.verify_object import _verify_one
158 repo = _make_repo(tmp_path)
159 oid = _write_object(repo, b"id check")
160 result = _verify_one(repo, oid)
161 assert result["object_id"] == oid
162
163 def test_error_is_none_when_ok(self, tmp_path: pathlib.Path) -> None:
164 from muse.cli.commands.verify_object import _verify_one
165 repo = _make_repo(tmp_path)
166 oid = _write_object(repo, b"clean")
167 result = _verify_one(repo, oid)
168 assert result["ok"] is True
169 assert result["error"] is None
170
171 def test_size_counted_during_hash(self, tmp_path: pathlib.Path) -> None:
172 from muse.cli.commands.verify_object import _verify_one
173 repo = _make_repo(tmp_path)
174 content = b"x" * 12345
175 oid = _write_object(repo, content)
176 result = _verify_one(repo, oid)
177 assert result["size_bytes"] == 12345
178
179 def test_zero_byte_object_ok(self, tmp_path: pathlib.Path) -> None:
180 from muse.cli.commands.verify_object import _verify_one
181 repo = _make_repo(tmp_path)
182 oid = _write_object(repo, b"")
183 result = _verify_one(repo, oid)
184 assert result["ok"] is True
185 assert result["size_bytes"] == 0
186
187 def test_missing_object_not_ok(self, tmp_path: pathlib.Path) -> None:
188 from muse.cli.commands.verify_object import _verify_one
189 repo = _make_repo(tmp_path)
190 result = _verify_one(repo, blob_id(b"nonexistent object"))
191 assert result["ok"] is False
192 assert "not found" in (result["error"] or "")
193 assert result["size_bytes"] is None
194
195 def test_corrupt_object_mismatch(self, tmp_path: pathlib.Path) -> None:
196 from muse.cli.commands.verify_object import _verify_one
197 repo = _make_repo(tmp_path)
198 oid = _write_object(repo, b"original content")
199 _corrupt_object(repo, oid)
200 result = _verify_one(repo, oid)
201 assert result["ok"] is False
202 assert "mismatch" in (result["error"] or "")
203
204 def test_corrupt_object_has_size_bytes(self, tmp_path: pathlib.Path) -> None:
205 """Even on hash mismatch, size_bytes is populated (bytes were read)."""
206 from muse.cli.commands.verify_object import _verify_one
207 repo = _make_repo(tmp_path)
208 oid = _write_object(repo, b"original content")
209 _corrupt_object(repo, oid)
210 result = _verify_one(repo, oid)
211 assert result["size_bytes"] is not None
212 assert result["size_bytes"] > 0
213
214 def test_truncated_object_mismatch(self, tmp_path: pathlib.Path) -> None:
215 from muse.cli.commands.verify_object import _verify_one
216 repo = _make_repo(tmp_path)
217 oid = _write_object(repo, b"original content that will be truncated")
218 _truncate_object(repo, oid, keep_bytes=4)
219 result = _verify_one(repo, oid)
220 assert result["ok"] is False
221 assert "mismatch" in (result["error"] or "")
222
223 def test_empty_truncated_object_mismatch(self, tmp_path: pathlib.Path) -> None:
224 from muse.cli.commands.verify_object import _verify_one
225 repo = _make_repo(tmp_path)
226 oid = _write_object(repo, b"will be emptied")
227 _truncate_object(repo, oid, keep_bytes=0)
228 result = _verify_one(repo, oid)
229 assert result["ok"] is False
230
231 def test_invalid_object_id_format(self, tmp_path: pathlib.Path) -> None:
232 from muse.cli.commands.verify_object import _verify_one
233 repo = _make_repo(tmp_path)
234 result = _verify_one(repo, "not-a-sha256")
235 assert result["ok"] is False
236 assert result["error"] is not None
237
238 def test_invalid_object_id_never_raises(self, tmp_path: pathlib.Path) -> None:
239 from muse.cli.commands.verify_object import _verify_one
240 repo = _make_repo(tmp_path)
241 result = _verify_one(repo, "\x00" * 64)
242 assert isinstance(result, dict)
243 assert result["ok"] is False
244
245 def test_io_error_returns_error_dict(self, tmp_path: pathlib.Path) -> None:
246 """OSError during read returns an error result, never raises."""
247 from muse.cli.commands.verify_object import _verify_one
248 repo = _make_repo(tmp_path)
249 oid = _write_object(repo, b"to be made unreadable")
250 obj_file = object_path(repo, oid)
251 obj_file.chmod(0o000)
252 try:
253 result = _verify_one(repo, oid)
254 assert result["ok"] is False
255 assert result["error"] is not None
256 assert "I/O error" in (result["error"] or "")
257 finally:
258 obj_file.chmod(0o644)
259
260
261 class TestObjectResultSchema:
262 def test_fields(self) -> None:
263 from muse.cli.commands.verify_object import _ObjectResult
264 assert set(_ObjectResult.__annotations__) == {"object_id", "ok", "size_bytes", "error"}
265
266
267 class TestChunkConstant:
268 def test_chunk_is_power_of_two(self) -> None:
269 from muse.cli.commands.verify_object import _CHUNK
270 assert _CHUNK > 0
271 assert (_CHUNK & (_CHUNK - 1)) == 0
272
273
274 # ---------------------------------------------------------------------------
275 # Integration — JSON output
276 # ---------------------------------------------------------------------------
277
278
279 class TestJsonOutput:
280 def test_valid_object_all_ok(self, tmp_path: pathlib.Path) -> None:
281 repo = _make_repo(tmp_path)
282 oid = _write_object(repo, _FAKE_CONTENT)
283 result = _vo(repo, "--json", oid)
284 assert result.exit_code == 0
285 data = json.loads(result.output)
286 assert data["all_ok"] is True
287 assert data["checked"] == 1
288 assert data["failed"] == 0
289 assert data["results"][0]["ok"] is True
290 assert data["results"][0]["size_bytes"] == len(_FAKE_CONTENT)
291
292 def test_missing_object_fails(self, tmp_path: pathlib.Path) -> None:
293 repo = _make_repo(tmp_path)
294 result = _vo(repo, "--json", blob_id(b"nonexistent object"))
295 assert result.exit_code == ExitCode.USER_ERROR
296 data = json.loads(result.output)
297 assert data["all_ok"] is False
298 assert data["failed"] == 1
299
300 def test_corrupt_object_fails(self, tmp_path: pathlib.Path) -> None:
301 repo = _make_repo(tmp_path)
302 oid = _write_object(repo, b"good content")
303 _corrupt_object(repo, oid)
304 result = _vo(repo, "--json", oid)
305 assert result.exit_code == ExitCode.USER_ERROR
306 data = json.loads(result.output)
307 assert data["results"][0]["ok"] is False
308 assert "mismatch" in data["results"][0]["error"]
309
310 def test_mixed_pass_fail(self, tmp_path: pathlib.Path) -> None:
311 repo = _make_repo(tmp_path)
312 good = _write_object(repo, b"good")
313 bad = blob_id(b"nonexistent object b")
314 result = _vo(repo, "--json", good, bad)
315 assert result.exit_code == ExitCode.USER_ERROR
316 data = json.loads(result.output)
317 assert data["checked"] == 2
318 assert data["failed"] == 1
319
320 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
321 repo = _make_repo(tmp_path)
322 oid = _write_object(repo, b"data")
323 result = _vo(repo, "--json", oid)
324 assert result.exit_code == 0
325 assert "all_ok" in json.loads(result.output)
326
327 def test_duration_ms_and_exit_code_present(self, tmp_path: pathlib.Path) -> None:
328 repo = _make_repo(tmp_path)
329 oid = _write_object(repo, _FAKE_CONTENT)
330 data = json.loads(_vo(repo, "--json", oid).output)
331 assert "duration_ms" in data
332 assert isinstance(data["duration_ms"], float)
333 assert data["duration_ms"] >= 0.0
334 assert data["exit_code"] == 0
335
336 def test_exit_code_nonzero_on_failure(self, tmp_path: pathlib.Path) -> None:
337 repo = _make_repo(tmp_path)
338 data = json.loads(_vo(repo, "--json", blob_id(b"nonexistent object")).output)
339 assert data["exit_code"] != 0
340 assert data["duration_ms"] >= 0.0
341
342 def test_results_order_matches_input(self, tmp_path: pathlib.Path) -> None:
343 """Results must appear in the same order as the positional arguments."""
344 repo = _make_repo(tmp_path)
345 oids = [_write_object(repo, f"ordered {i}".encode()) for i in range(5)]
346 data = json.loads(_vo(repo, "--json", *oids).output)
347 returned = [r["object_id"] for r in data["results"]]
348 assert returned == oids
349
350 def test_checked_equals_len_results(self, tmp_path: pathlib.Path) -> None:
351 repo = _make_repo(tmp_path)
352 oids = [_write_object(repo, f"cnt {i}".encode()) for i in range(3)]
353 data = json.loads(_vo(repo, "--json", *oids).output)
354 assert data["checked"] == len(data["results"])
355
356 def test_failed_count_matches_failed_results(self, tmp_path: pathlib.Path) -> None:
357 repo = _make_repo(tmp_path)
358 good = _write_object(repo, b"ok")
359 bad1 = blob_id(b"missing a")
360 bad2 = blob_id(b"missing b")
361 data = json.loads(_vo(repo, "--json", good, bad1, bad2).output)
362 assert data["failed"] == sum(1 for r in data["results"] if not r["ok"])
363 assert data["failed"] == 2
364
365 def test_error_null_when_ok(self, tmp_path: pathlib.Path) -> None:
366 repo = _make_repo(tmp_path)
367 oid = _write_object(repo, b"clean object")
368 data = json.loads(_vo(repo, "--json", oid).output)
369 assert data["results"][0]["error"] is None
370
371 def test_duplicate_id_verified_twice(self, tmp_path: pathlib.Path) -> None:
372 """Passing the same OID twice verifies it twice — no implicit dedup."""
373 repo = _make_repo(tmp_path)
374 oid = _write_object(repo, b"dedup test")
375 data = json.loads(_vo(repo, "--json", oid, oid).output)
376 assert data["checked"] == 2
377 assert data["all_ok"] is True
378
379
380 # ---------------------------------------------------------------------------
381 # Integration — text output
382 # ---------------------------------------------------------------------------
383
384
385 class TestTextOutput:
386 def test_ok_label_and_size(self, tmp_path: pathlib.Path) -> None:
387 repo = _make_repo(tmp_path)
388 oid = _write_object(repo, _FAKE_CONTENT)
389 result = _vo(repo, oid)
390 assert result.exit_code == 0
391 assert "OK" in result.output
392 assert str(len(_FAKE_CONTENT)) in result.output
393
394 def test_fail_label_on_missing(self, tmp_path: pathlib.Path) -> None:
395 repo = _make_repo(tmp_path)
396 result = _vo(repo, blob_id(b"nonexistent object c"))
397 assert "FAIL" in result.output
398 assert result.exit_code == ExitCode.USER_ERROR
399
400 def test_summary_line_present(self, tmp_path: pathlib.Path) -> None:
401 """Text mode always ends with a Checked/Failed summary line."""
402 repo = _make_repo(tmp_path)
403 oid = _write_object(repo, b"summary test")
404 result = _vo(repo, oid)
405 assert "Checked:" in result.output
406 assert "Failed:" in result.output
407
408 def test_summary_reflects_counts(self, tmp_path: pathlib.Path) -> None:
409 repo = _make_repo(tmp_path)
410 good = _write_object(repo, b"good")
411 bad = blob_id(b"missing for summary")
412 result = _vo(repo, good, bad)
413 assert "Checked: 2" in result.output
414 assert "Failed: 1" in result.output
415
416 def test_summary_all_pass(self, tmp_path: pathlib.Path) -> None:
417 repo = _make_repo(tmp_path)
418 for i in range(3):
419 _write_object(repo, f"text pass {i}".encode())
420 result = _vo(repo, "--all")
421 assert "Checked: 3" in result.output
422 assert "Failed: 0" in result.output
423
424
425 # ---------------------------------------------------------------------------
426 # Integration — --quiet mode
427 # ---------------------------------------------------------------------------
428
429
430 class TestQuietMode:
431 def test_all_ok_exits_0(self, tmp_path: pathlib.Path) -> None:
432 repo = _make_repo(tmp_path)
433 oid = _write_object(repo, _FAKE_CONTENT)
434 result = _vo(repo, "--quiet", oid)
435 assert result.exit_code == 0
436 assert result.output.strip() == ""
437
438 def test_failure_exits_1(self, tmp_path: pathlib.Path) -> None:
439 repo = _make_repo(tmp_path)
440 result = _vo(repo, "--quiet", blob_id(b"nonexistent object d"))
441 assert result.exit_code == ExitCode.USER_ERROR
442 assert result.output.strip() == ""
443
444 def test_quiet_with_text_format_no_output(self, tmp_path: pathlib.Path) -> None:
445 """--quiet suppresses output regardless of --format."""
446 repo = _make_repo(tmp_path)
447 oid = _write_object(repo, b"quiet text")
448 result = _vo(repo, "--quiet", oid)
449 assert result.output.strip() == ""
450
451
452 # ---------------------------------------------------------------------------
453 # Integration — --all (fsck mode)
454 # ---------------------------------------------------------------------------
455
456
457 class TestAllMode:
458 def test_empty_store_all_ok(self, tmp_path: pathlib.Path) -> None:
459 repo = _make_repo(tmp_path)
460 data = json.loads(_vo(repo, "--all", "--json").output)
461 assert data["all_ok"] is True
462 assert data["checked"] == 0
463
464 def test_all_finds_written_objects(self, tmp_path: pathlib.Path) -> None:
465 repo = _make_repo(tmp_path)
466 for i in range(5):
467 _write_object(repo, f"content {i}".encode())
468 data = json.loads(_vo(repo, "--all", "--json").output)
469 assert data["checked"] == 5
470 assert data["all_ok"] is True
471
472 def test_all_detects_corruption(self, tmp_path: pathlib.Path) -> None:
473 repo = _make_repo(tmp_path)
474 oid = _write_object(repo, b"good data")
475 _corrupt_object(repo, oid)
476 data = json.loads(_vo(repo, "--all", "--json").output)
477 assert data["failed"] == 1
478
479 def test_all_plus_explicit_ids_rejected(self, tmp_path: pathlib.Path) -> None:
480 repo = _make_repo(tmp_path)
481 result = _vo(repo, "--all", blob_id(b"explicit id arg"))
482 assert result.exit_code == ExitCode.USER_ERROR
483 assert result.stdout_bytes == b""
484
485 def test_all_plus_stdin_rejected(self, tmp_path: pathlib.Path) -> None:
486 """--all + --stdin is rejected for consistency with --all + positional."""
487 repo = _make_repo(tmp_path)
488 oid = _write_object(repo, b"stdin data")
489 result = _vo(repo, "--all", "--stdin", stdin=f"{oid}\n")
490 assert result.exit_code == ExitCode.USER_ERROR
491 assert result.stdout_bytes == b""
492
493 def test_all_quiet(self, tmp_path: pathlib.Path) -> None:
494 repo = _make_repo(tmp_path)
495 _write_object(repo, b"content")
496 result = _vo(repo, "--all", "--quiet")
497 assert result.exit_code == 0
498 assert result.output.strip() == ""
499
500
501 # ---------------------------------------------------------------------------
502 # Integration — --stdin
503 # ---------------------------------------------------------------------------
504
505
506 class TestStdinMode:
507 def test_reads_ids_from_stdin(self, tmp_path: pathlib.Path) -> None:
508 repo = _make_repo(tmp_path)
509 oid = _write_object(repo, _FAKE_CONTENT)
510 data = json.loads(_vo(repo, "--stdin", "--json", stdin=f"{oid}\n").output)
511 assert data["checked"] == 1
512 assert data["all_ok"] is True
513
514 def test_comments_and_blank_lines_skipped(self, tmp_path: pathlib.Path) -> None:
515 repo = _make_repo(tmp_path)
516 oid = _write_object(repo, _FAKE_CONTENT)
517 data = json.loads(_vo(repo, "--stdin", "--json", stdin=f"\n# comment\n{oid}\n\n").output)
518 assert data["checked"] == 1
519
520 def test_stdin_combines_with_positional(self, tmp_path: pathlib.Path) -> None:
521 repo = _make_repo(tmp_path)
522 oid1 = _write_object(repo, b"one")
523 oid2 = _write_object(repo, b"two")
524 data = json.loads(_vo(repo, "--stdin", "--json", oid1, stdin=f"{oid2}\n").output)
525 assert data["checked"] == 2
526
527 def test_empty_stdin_no_explicit_errors(self, tmp_path: pathlib.Path) -> None:
528 repo = _make_repo(tmp_path)
529 result = _vo(repo, "--stdin", "--json", stdin="")
530 assert result.exit_code == ExitCode.USER_ERROR
531
532 def test_crlf_line_endings_stripped(self, tmp_path: pathlib.Path) -> None:
533 """Windows CRLF line endings must not corrupt the object ID."""
534 repo = _make_repo(tmp_path)
535 oid = _write_object(repo, b"crlf test")
536 data = json.loads(_vo(repo, "--stdin", "--json", stdin=f"{oid}\r\n").output)
537 assert data["all_ok"] is True
538 assert data["results"][0]["object_id"] == oid
539
540
541 # ---------------------------------------------------------------------------
542 # Integration — --fail-fast
543 # ---------------------------------------------------------------------------
544
545
546 class TestFailFast:
547 def test_stops_after_first_failure(self, tmp_path: pathlib.Path) -> None:
548 """With --fail-fast, only the first failing result appears in output."""
549 repo = _make_repo(tmp_path)
550 bad1 = blob_id(b"missing ff a")
551 bad2 = blob_id(b"missing ff b")
552 good = _write_object(repo, b"good after bad")
553 # bad1, bad2, good — should stop after bad1
554 data = json.loads(_vo(repo, "--fail-fast", "--json", bad1, bad2, good).output)
555 assert data["checked"] == 1
556 assert data["failed"] == 1
557 assert data["all_ok"] is False
558
559 def test_no_effect_when_all_pass(self, tmp_path: pathlib.Path) -> None:
560 """--fail-fast is a no-op when every object passes."""
561 repo = _make_repo(tmp_path)
562 oids = [_write_object(repo, f"ff pass {i}".encode()) for i in range(5)]
563 data = json.loads(_vo(repo, "--fail-fast", "--json", *oids).output)
564 assert data["checked"] == 5
565 assert data["all_ok"] is True
566
567 def test_fail_fast_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
568 repo = _make_repo(tmp_path)
569 result = _vo(repo, "--fail-fast", "--json", blob_id(b"missing ff c"))
570 assert result.exit_code == ExitCode.USER_ERROR
571
572 def test_fail_fast_with_all(self, tmp_path: pathlib.Path) -> None:
573 """--fail-fast + --all stops the scan on the first corrupt object."""
574 repo = _make_repo(tmp_path)
575 for i in range(10):
576 _write_object(repo, f"store {i}".encode())
577 # Corrupt one object somewhere in the store.
578 from muse.cli.commands.verify_object import _iter_all_object_ids
579 all_ids = _iter_all_object_ids(repo)
580 _corrupt_object(repo, all_ids[0])
581 data = json.loads(_vo(repo, "--all", "--fail-fast", "--json").output)
582 # Should have stopped early — checked < 10.
583 assert data["checked"] < len(all_ids)
584 assert data["failed"] == 1
585
586 def test_fail_fast_duration_ms_present(self, tmp_path: pathlib.Path) -> None:
587 repo = _make_repo(tmp_path)
588 data = json.loads(_vo(repo, "--fail-fast", "--json", blob_id(b"missing ff d")).output)
589 assert "duration_ms" in data
590 assert data["duration_ms"] >= 0.0
591
592
593 # ---------------------------------------------------------------------------
594 # Security
595 # ---------------------------------------------------------------------------
596
597
598 class TestSecurity:
599 def test_format_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
600 repo = _make_repo(tmp_path)
601 result = _vo(repo, fake_id("a"))
602 assert result.exit_code == ExitCode.USER_ERROR
603 assert "Traceback" not in result.output
604
605 def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None:
606 repo = _make_repo(tmp_path)
607 result = _vo(repo, fake_id("b"))
608 assert "Traceback" not in result.output
609
610 def test_ansi_in_error_message_stripped_text(self, tmp_path: pathlib.Path) -> None:
611 repo = _make_repo(tmp_path)
612 result = _vo(repo, blob_id(b"nonexistent"))
613 assert "\x1b" not in result.output
614
615 def test_invalid_id_returns_error_not_crash(self, tmp_path: pathlib.Path) -> None:
616 repo = _make_repo(tmp_path)
617 result = _vo(repo, "not-a-sha256")
618 assert result.exit_code == ExitCode.USER_ERROR
619 assert "Traceback" not in result.output
620
621 def test_no_ids_errors_to_stderr(self, tmp_path: pathlib.Path) -> None:
622 repo = _make_repo(tmp_path)
623 result = _vo(repo)
624 assert result.exit_code == ExitCode.USER_ERROR
625 assert "error" in result.stderr.lower()
626
627 def test_path_traversal_in_object_id_rejected(self, tmp_path: pathlib.Path) -> None:
628 """Path-traversal-looking IDs must be rejected by validation before any I/O."""
629 repo = _make_repo(tmp_path)
630 traversal = "sha256:../../etc/passwd" + "a" * 50
631 result = _vo(repo, "--json", traversal)
632 # Validation must reject it — never attempts to open a path.
633 assert result.exit_code == ExitCode.USER_ERROR
634 data = json.loads(result.output)
635 # The error message explains the format violation, not an fs operation.
636 assert data["results"][0]["ok"] is False
637 assert "expected" in data["results"][0]["error"]
638
639 def test_unicode_in_object_id_rejected(self, tmp_path: pathlib.Path) -> None:
640 repo = _make_repo(tmp_path)
641 result = _vo(repo, "sha256:café" + "a" * 60)
642 assert result.exit_code == ExitCode.USER_ERROR
643
644 def test_symlink_shard_directory_skipped(self, tmp_path: pathlib.Path) -> None:
645 """A symlinked shard directory must not be followed during --all."""
646 from muse.cli.commands.verify_object import _iter_all_object_ids
647 from muse.core.object_store import objects_dir
648 repo = _make_repo(tmp_path)
649 # Write a real object so the algo dir exists.
650 _write_object(repo, b"real")
651 algo_dir = objects_dir(repo) / "sha256"
652 # Add a symlink that points outside the repo.
653 sym_shard = algo_dir / "ff"
654 sym_shard.symlink_to(tmp_path)
655 ids = _iter_all_object_ids(repo)
656 # The symlinked shard's entries must not appear.
657 assert all(oid.startswith("sha256:") for oid in ids)
658
659 def test_crlf_injection_in_stdin_does_not_corrupt_id(self, tmp_path: pathlib.Path) -> None:
660 """A \r embedded in a stdin line must not be part of the stored OID."""
661 repo = _make_repo(tmp_path)
662 oid = _write_object(repo, b"crlf injection")
663 # Feed oid with embedded \r before the newline.
664 data = json.loads(_vo(repo, "--stdin", "--json", stdin=f"{oid}\r\n").output)
665 assert data["all_ok"] is True
666
667 def test_all_error_goes_to_stderr_not_stdout(self, tmp_path: pathlib.Path) -> None:
668 """Argument errors for --all always land on stderr, stdout stays empty."""
669 repo = _make_repo(tmp_path)
670 result = _vo(repo, "--all", "--stdin", stdin="")
671 assert result.stdout_bytes == b""
672 assert len(result.stderr) > 0
673
674
675 # ---------------------------------------------------------------------------
676 # Data integrity
677 # ---------------------------------------------------------------------------
678
679
680 class TestDataIntegrity:
681 def test_zero_byte_blob_round_trips(self, tmp_path: pathlib.Path) -> None:
682 """A zero-byte object has a well-defined SHA-256 and must verify clean."""
683 repo = _make_repo(tmp_path)
684 oid = _write_object(repo, b"")
685 data = json.loads(_vo(repo, "--json", oid).output)
686 assert data["all_ok"] is True
687 assert data["results"][0]["size_bytes"] == 0
688
689 def test_truncated_file_is_hash_mismatch(self, tmp_path: pathlib.Path) -> None:
690 repo = _make_repo(tmp_path)
691 oid = _write_object(repo, b"file that will be truncated")
692 _truncate_object(repo, oid, keep_bytes=3)
693 data = json.loads(_vo(repo, "--json", oid).output)
694 assert data["results"][0]["ok"] is False
695 assert "mismatch" in data["results"][0]["error"]
696
697 def test_completely_emptied_file_is_hash_mismatch(self, tmp_path: pathlib.Path) -> None:
698 repo = _make_repo(tmp_path)
699 oid = _write_object(repo, b"non-empty content")
700 _truncate_object(repo, oid, keep_bytes=0)
701 data = json.loads(_vo(repo, "--json", oid).output)
702 assert data["results"][0]["ok"] is False
703
704 def test_large_object_streams_without_loading_all(self, tmp_path: pathlib.Path) -> None:
705 """A 4 MiB object must verify correctly via streaming (no heap spike)."""
706 repo = _make_repo(tmp_path)
707 content = b"a" * (4 * 1024 * 1024)
708 oid = _write_object(repo, content)
709 data = json.loads(_vo(repo, "--json", oid).output)
710 assert data["all_ok"] is True
711 assert data["results"][0]["size_bytes"] == len(content)
712
713 def test_multiple_corrupt_objects_all_reported(self, tmp_path: pathlib.Path) -> None:
714 """All corruptions are reported — not just the first one."""
715 repo = _make_repo(tmp_path)
716 oids = [_write_object(repo, f"corrupt me {i}".encode()) for i in range(3)]
717 for oid in oids:
718 _corrupt_object(repo, oid)
719 data = json.loads(_vo(repo, "--json", *oids).output)
720 assert data["failed"] == 3
721 assert data["all_ok"] is False
722
723
724 # ---------------------------------------------------------------------------
725 # Stress
726 # ---------------------------------------------------------------------------
727
728
729 class TestStress:
730 def test_100_object_store_all_pass(self, tmp_path: pathlib.Path) -> None:
731 repo = _make_repo(tmp_path)
732 for i in range(100):
733 _write_object(repo, f"stress content {i}".encode())
734 data = json.loads(_vo(repo, "--all", "--json").output)
735 assert data["checked"] == 100
736 assert data["all_ok"] is True
737
738 def test_1000_object_store_all_pass(self, tmp_path: pathlib.Path) -> None:
739 repo = _make_repo(tmp_path)
740 for i in range(1000):
741 _write_object(repo, f"large stress {i}".encode())
742 data = json.loads(_vo(repo, "--all", "--json").output)
743 assert data["checked"] == 1000
744 assert data["all_ok"] is True
745
746 def test_200_sequential_verifies(self, tmp_path: pathlib.Path) -> None:
747 repo = _make_repo(tmp_path)
748 oid = _write_object(repo, _FAKE_CONTENT)
749 for i in range(200):
750 result = _vo(repo, oid)
751 assert result.exit_code == 0, f"failed at iteration {i}"
752
753 def test_stdin_200_ids(self, tmp_path: pathlib.Path) -> None:
754 repo = _make_repo(tmp_path)
755 oids = [_write_object(repo, f"content_{i}".encode()) for i in range(200)]
756 data = json.loads(_vo(repo, "--stdin", "--json", stdin="\n".join(oids) + "\n").output)
757 assert data["checked"] == 200
758 assert data["all_ok"] is True
759
760 def test_duration_ms_bounded_for_small_op(self, tmp_path: pathlib.Path) -> None:
761 """Verifying one small object should complete in well under 5 seconds."""
762 repo = _make_repo(tmp_path)
763 oid = _write_object(repo, b"small")
764 data = json.loads(_vo(repo, "--json", oid).output)
765 assert data["duration_ms"] < 5_000
766
767
768 # ---------------------------------------------------------------------------
769 # Flag registration
770 # ---------------------------------------------------------------------------
771
772
773 class TestRegisterFlags:
774 def _parse(self, *args: str):
775 import argparse
776 from muse.cli.commands.verify_object import register
777 p = argparse.ArgumentParser()
778 sub = p.add_subparsers()
779 register(sub)
780 return p.parse_args(["verify-object", *args])
781
782 def test_default_json_out_is_false(self) -> None:
783 ns = self._parse(fake_id("a"))
784 assert ns.json_out is False
785
786 def test_json_flag_sets_json_out(self) -> None:
787 ns = self._parse("--json", fake_id("a"))
788 assert ns.json_out is True
789
790 def test_j_shorthand_sets_json_out(self) -> None:
791 ns = self._parse("-j", fake_id("a"))
792 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago