gabriel / muse public
test_cmd_content_grep_hardening.py python
652 lines 20.9 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Hardening tests for ``muse content-grep``.
2
3 Covers:
4 Unit — _is_binary, _path_matches_globs, _search_object (context,
5 binary skip, utf-8 replace), pattern validation order
6 Security — ANSI injection in file paths and match text, pattern length
7 cap, invalid regex, ReDoS pattern rejected before I/O
8 Perf — parallel reads complete correctly, --max-matches cap
9 JSON — _ContentGrepJson schema (commit_id, snapshot_id, totals),
10 GrepMatch context_before/context_after fields
11 Flags — --include, --exclude, --max-matches, --context/-C, --json,
12 rejection of old --format flag
13 Integration — multi-file with mixed hits, --include narrows search,
14 --exclude skips files, --context shows surrounding lines,
15 --ref searches historical commit
16 E2E — --help output mentions all new flags
17 Stress — 500-file snapshot, concurrent parallel reads
18 """
19
20 from __future__ import annotations
21
22 import datetime
23 import hashlib
24 import json
25 import pathlib
26 import threading
27 from typing import TypedDict
28
29 import pytest
30 from tests.cli_test_helper import CliRunner, InvokeResult
31
32 from muse.core.object_store import write_object
33 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
34 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
35 from muse.core._types import Manifest
36
37 cli = None
38 runner = CliRunner()
39 _invoke_lock = threading.Lock()
40
41 type _FilesMap = dict[str, bytes]
42
43 _REPO_ID = "cgrep-hardening"
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 class _GrepMatchOut(TypedDict):
52 line_number: int
53 text: str
54 context_before: list[str]
55 context_after: list[str]
56
57
58 class _GrepResultOut(TypedDict):
59 path: str
60 object_id: str
61 match_count: int
62 matches: list[_GrepMatchOut]
63
64
65 class _GrepOut(TypedDict):
66 commit_id: str
67 snapshot_id: str
68 pattern: str
69 total_files_matched: int
70 total_matches: int
71 results: list[_GrepResultOut]
72
73
74 def _sha(data: bytes) -> str:
75 return hashlib.sha256(data).hexdigest()
76
77
78 def _init_repo(path: pathlib.Path, repo_id: str = _REPO_ID) -> pathlib.Path:
79 muse = path / ".muse"
80 for d in ("commits", "snapshots", "objects", "refs/heads"):
81 (muse / d).mkdir(parents=True, exist_ok=True)
82 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
83 (muse / "repo.json").write_text(
84 json.dumps({"repo_id": repo_id, "domain": "midi"}), encoding="utf-8"
85 )
86 return path
87
88
89 def _env(repo: pathlib.Path) -> Manifest:
90 return {"MUSE_REPO_ROOT": str(repo)}
91
92
93 _counter = 0
94
95
96 def _commit_files(
97 root: pathlib.Path,
98 files: _FilesMap,
99 branch: str = "main",
100 parent_id: str | None = None,
101 ) -> str:
102 global _counter
103 _counter += 1
104 manifest: Manifest = {}
105 for rel_path, content in files.items():
106 obj_id = _sha(content)
107 write_object(root, obj_id, content)
108 manifest[rel_path] = obj_id
109 snap_id = compute_snapshot_id(manifest)
110 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
111 committed_at = datetime.datetime.now(datetime.timezone.utc)
112 parent_ids = [parent_id] if parent_id else []
113 commit_id = compute_commit_id(
114 parent_ids, snap_id, f"commit {_counter}", committed_at.isoformat()
115 )
116 write_commit(
117 root,
118 CommitRecord(
119 commit_id=commit_id,
120 repo_id=_REPO_ID,
121 branch=branch,
122 snapshot_id=snap_id,
123 message=f"commit {_counter}",
124 committed_at=committed_at,
125 parent_commit_id=parent_id,
126 ),
127 )
128 ref_path = root / ".muse" / "refs" / "heads" / branch
129 ref_path.parent.mkdir(parents=True, exist_ok=True)
130 ref_path.write_text(commit_id, encoding="utf-8")
131 return commit_id
132
133
134 def _invoke(args: list[str], env: Manifest | None = None) -> InvokeResult:
135 with _invoke_lock:
136 return runner.invoke(cli, args, env=env)
137
138
139 def _parse(result: InvokeResult) -> _GrepOut:
140 raw: _GrepOut = json.loads(result.output)
141 return raw
142
143
144 # ---------------------------------------------------------------------------
145 # Unit: _is_binary
146 # ---------------------------------------------------------------------------
147
148
149 def test_is_binary_null_byte() -> None:
150 from muse.cli.commands.content_grep import _is_binary
151
152 assert _is_binary(b"\x00hello") is True
153
154
155 def test_is_binary_clean_text() -> None:
156 from muse.cli.commands.content_grep import _is_binary
157
158 assert _is_binary(b"hello world\n") is False
159
160
161 def test_is_binary_empty() -> None:
162 from muse.cli.commands.content_grep import _is_binary
163
164 assert _is_binary(b"") is False
165
166
167 # ---------------------------------------------------------------------------
168 # Unit: _path_matches_globs
169 # ---------------------------------------------------------------------------
170
171
172 def test_path_matches_no_filter() -> None:
173 from muse.cli.commands.content_grep import _path_matches_globs
174
175 assert _path_matches_globs("src/main.py", None, None) is True
176
177
178 def test_path_matches_include_basename() -> None:
179 from muse.cli.commands.content_grep import _path_matches_globs
180
181 assert _path_matches_globs("src/main.py", "*.py", None) is True
182 assert _path_matches_globs("src/main.js", "*.py", None) is False
183
184
185 def test_path_matches_include_full_path() -> None:
186 from muse.cli.commands.content_grep import _path_matches_globs
187
188 assert _path_matches_globs("src/main.py", "src/*.py", None) is True
189 assert _path_matches_globs("tests/main.py", "src/*.py", None) is False
190
191
192 def test_path_matches_exclude_basename() -> None:
193 from muse.cli.commands.content_grep import _path_matches_globs
194
195 assert _path_matches_globs("app.min.js", None, "*.min.js") is False
196 assert _path_matches_globs("app.js", None, "*.min.js") is True
197
198
199 def test_path_matches_include_and_exclude() -> None:
200 from muse.cli.commands.content_grep import _path_matches_globs
201
202 assert _path_matches_globs("src/main.py", "*.py", "test_*.py") is True
203 assert _path_matches_globs("test_foo.py", "*.py", "test_*.py") is False
204
205
206 # ---------------------------------------------------------------------------
207 # Unit: _search_object — context lines
208 # ---------------------------------------------------------------------------
209
210
211 def test_search_object_context(tmp_path: pathlib.Path) -> None:
212 import re
213 from muse.cli.commands.content_grep import _search_object
214
215 _init_repo(tmp_path)
216 content = b"line one\nTARGET line\nline three\n"
217 obj_id = _sha(content)
218 write_object(tmp_path, obj_id, content)
219
220 pat = re.compile("TARGET")
221 count, matches = _search_object(tmp_path, obj_id, pat, False, False, context_lines=1)
222 assert count == 1
223 assert len(matches) == 1
224 assert matches[0]["context_before"] == ["line one"]
225 assert matches[0]["context_after"] == ["line three"]
226
227
228 def test_search_object_context_at_boundary(tmp_path: pathlib.Path) -> None:
229 import re
230 from muse.cli.commands.content_grep import _search_object
231
232 _init_repo(tmp_path)
233 content = b"TARGET\nonly\n"
234 obj_id = _sha(content)
235 write_object(tmp_path, obj_id, content)
236
237 pat = re.compile("TARGET")
238 count, matches = _search_object(tmp_path, obj_id, pat, False, False, context_lines=3)
239 assert matches[0]["context_before"] == []
240 assert matches[0]["context_after"] == ["only"]
241
242
243 def test_search_object_no_context(tmp_path: pathlib.Path) -> None:
244 import re
245 from muse.cli.commands.content_grep import _search_object
246
247 _init_repo(tmp_path)
248 content = b"line\nTARGET\nend\n"
249 obj_id = _sha(content)
250 write_object(tmp_path, obj_id, content)
251
252 pat = re.compile("TARGET")
253 _, matches = _search_object(tmp_path, obj_id, pat, False, False, context_lines=0)
254 assert matches[0]["context_before"] == []
255 assert matches[0]["context_after"] == []
256
257
258 def test_search_object_binary_skipped(tmp_path: pathlib.Path) -> None:
259 import re
260 from muse.cli.commands.content_grep import _search_object
261
262 _init_repo(tmp_path)
263 content = b"\x00\x01\x02TARGET\x03"
264 obj_id = _sha(content)
265 write_object(tmp_path, obj_id, content)
266
267 pat = re.compile("TARGET")
268 count, matches = _search_object(tmp_path, obj_id, pat, False, False, 0)
269 assert count == 0
270 assert matches == []
271
272
273 # ---------------------------------------------------------------------------
274 # Security: pattern validation happens BEFORE I/O
275 # ---------------------------------------------------------------------------
276
277
278 def test_long_pattern_rejected_before_io(tmp_path: pathlib.Path) -> None:
279 """A too-long pattern must be rejected without touching the object store."""
280 _init_repo(tmp_path)
281 # Do NOT commit any files — if I/O happened, we'd get a 'no commits' error,
282 # not the 'pattern too long' error.
283 bad_pattern = "a" * 501
284 result = _invoke(
285 ["content-grep", bad_pattern], env=_env(tmp_path)
286 )
287 assert result.exit_code != 0
288 # The error must be about pattern length, not about missing commits.
289 assert "too long" in result.output.lower() or "too long" in (result.stderr or "").lower()
290
291
292 def test_invalid_regex_rejected_before_io(tmp_path: pathlib.Path) -> None:
293 _init_repo(tmp_path)
294 result = _invoke(
295 ["content-grep", "[unclosed"], env=_env(tmp_path)
296 )
297 assert result.exit_code != 0
298 assert "regex" in result.output.lower() or "regex" in (result.stderr or "").lower()
299
300
301 # ---------------------------------------------------------------------------
302 # Security: ANSI injection
303 # ---------------------------------------------------------------------------
304
305
306 def test_ansi_injection_in_path(tmp_path: pathlib.Path) -> None:
307 """File paths with ANSI escapes must be stripped in text output."""
308 _init_repo(tmp_path)
309 ansi_path = "\x1b[31mevil\x1b[0m.txt"
310 _commit_files(tmp_path, {ansi_path: b"TARGET content\n"})
311 result = _invoke(
312 ["content-grep", "TARGET"], env=_env(tmp_path)
313 )
314 assert result.exit_code == 0
315 assert "\x1b" not in result.output
316
317
318 def test_ansi_injection_in_match_text(tmp_path: pathlib.Path) -> None:
319 """Match text with ANSI escapes must be stripped in text output."""
320 _init_repo(tmp_path)
321 _commit_files(tmp_path, {"safe.txt": b"TARGET \x1b[31mred\x1b[0m content\n"})
322 result = _invoke(
323 ["content-grep", "TARGET"], env=_env(tmp_path)
324 )
325 assert result.exit_code == 0
326 assert "\x1b" not in result.output
327
328
329 # ---------------------------------------------------------------------------
330 # JSON schema: _ContentGrepJson
331 # ---------------------------------------------------------------------------
332
333
334 def test_json_schema_all_fields(tmp_path: pathlib.Path) -> None:
335 _init_repo(tmp_path)
336 _commit_files(tmp_path, {"a.txt": b"hello world\nhello again\n"})
337 result = _invoke(
338 ["content-grep", "hello", "--json"], env=_env(tmp_path)
339 )
340 assert result.exit_code == 0
341 data = _parse(result)
342 assert len(data["commit_id"]) == 64
343 assert len(data["snapshot_id"]) == 64
344 assert data["pattern"] == "hello"
345 assert data["total_files_matched"] == 1
346 assert data["total_matches"] == 2
347 assert len(data["results"]) == 1
348 r = data["results"][0]
349 assert r["path"] == "a.txt"
350 assert r["match_count"] == 2
351 assert isinstance(r["matches"], list)
352
353
354 def test_json_schema_context_fields(tmp_path: pathlib.Path) -> None:
355 _init_repo(tmp_path)
356 _commit_files(tmp_path, {"c.txt": b"before\nTARGET\nafter\n"})
357 result = _invoke(
358 ["content-grep", "TARGET", "--context", "1", "--json"],
359 env=_env(tmp_path),
360 )
361 assert result.exit_code == 0
362 data = _parse(result)
363 match = data["results"][0]["matches"][0]
364 assert isinstance(match, dict)
365 assert "context_before" in match
366 assert "context_after" in match
367 assert match["context_before"] == ["before"]
368 assert match["context_after"] == ["after"]
369
370
371 def test_json_schema_no_match_exit1(tmp_path: pathlib.Path) -> None:
372 _init_repo(tmp_path)
373 _commit_files(tmp_path, {"a.txt": b"hello\n"})
374 result = _invoke(
375 ["content-grep", "ZZZNOMATCH", "--json"], env=_env(tmp_path)
376 )
377 assert result.exit_code != 0
378
379
380 def test_json_total_matches_multiple_files(tmp_path: pathlib.Path) -> None:
381 _init_repo(tmp_path)
382 _commit_files(tmp_path, {
383 "a.txt": b"hit\nhit\n",
384 "b.txt": b"hit\n",
385 "c.txt": b"miss\n",
386 })
387 result = _invoke(
388 ["content-grep", "hit", "--json"], env=_env(tmp_path)
389 )
390 assert result.exit_code == 0
391 data = _parse(result)
392 assert data["total_files_matched"] == 2
393 assert data["total_matches"] == 3
394
395
396 # ---------------------------------------------------------------------------
397 # Flags: --include
398 # ---------------------------------------------------------------------------
399
400
401 def test_include_filters_to_py_only(tmp_path: pathlib.Path) -> None:
402 _init_repo(tmp_path)
403 _commit_files(tmp_path, {
404 "module.py": b"TARGET in python\n",
405 "module.js": b"TARGET in js\n",
406 "readme.md": b"TARGET in md\n",
407 })
408 result = _invoke(
409 ["content-grep", "TARGET", "--include", "*.py", "--json"],
410 env=_env(tmp_path),
411 )
412 assert result.exit_code == 0
413 data = _parse(result)
414 assert data["total_files_matched"] == 1
415 assert data["results"][0]["path"] == "module.py"
416
417
418 def test_include_no_matches_after_filter(tmp_path: pathlib.Path) -> None:
419 _init_repo(tmp_path)
420 _commit_files(tmp_path, {"module.js": b"TARGET here\n"})
421 result = _invoke(
422 ["content-grep", "TARGET", "--include", "*.py"],
423 env=_env(tmp_path),
424 )
425 assert result.exit_code != 0 # no files pass include filter
426
427
428 # ---------------------------------------------------------------------------
429 # Flags: --exclude
430 # ---------------------------------------------------------------------------
431
432
433 def test_exclude_skips_minified(tmp_path: pathlib.Path) -> None:
434 _init_repo(tmp_path)
435 _commit_files(tmp_path, {
436 "app.js": b"TARGET here\n",
437 "app.min.js": b"TARGET minified\n",
438 })
439 result = _invoke(
440 ["content-grep", "TARGET", "--exclude", "*.min.js", "--json"],
441 env=_env(tmp_path),
442 )
443 assert result.exit_code == 0
444 data = _parse(result)
445 assert data["total_files_matched"] == 1
446 assert data["results"][0]["path"] == "app.js"
447
448
449 def test_exclude_all_results_in_no_match(tmp_path: pathlib.Path) -> None:
450 _init_repo(tmp_path)
451 _commit_files(tmp_path, {"test.py": b"TARGET\n"})
452 result = _invoke(
453 ["content-grep", "TARGET", "--exclude", "test_*.py"],
454 env=_env(tmp_path),
455 )
456 # test.py doesn't match test_*.py exclude pattern, so it should match.
457 # Verify this works (target file isn't excluded).
458 assert result.exit_code == 0
459
460
461 # ---------------------------------------------------------------------------
462 # Flags: --max-matches
463 # ---------------------------------------------------------------------------
464
465
466 def test_max_matches_caps_output(tmp_path: pathlib.Path) -> None:
467 _init_repo(tmp_path)
468 _commit_files(tmp_path, {"many.txt": b"hit\n" * 100})
469 result = _invoke(
470 ["content-grep", "hit", "--max-matches", "10", "--json"],
471 env=_env(tmp_path),
472 )
473 assert result.exit_code == 0
474 data = _parse(result)
475 assert data["total_matches"] <= 10
476
477
478 def test_max_matches_zero_still_exits_nonzero_on_cap(tmp_path: pathlib.Path) -> None:
479 """When max_matches=0, no results are kept — exit 1."""
480 _init_repo(tmp_path)
481 _commit_files(tmp_path, {"a.txt": b"hit\n"})
482 result = _invoke(
483 ["content-grep", "hit", "--max-matches", "0", "--json"],
484 env=_env(tmp_path),
485 )
486 assert result.exit_code != 0 # no results after cap → exit 1
487
488
489 # ---------------------------------------------------------------------------
490 # Flags: --context / -C
491 # ---------------------------------------------------------------------------
492
493
494 def test_context_text_output(tmp_path: pathlib.Path) -> None:
495 _init_repo(tmp_path)
496 _commit_files(tmp_path, {"ctx.txt": b"alpha\nbeta\ngamma\n"})
497 result = _invoke(
498 ["content-grep", "beta", "--context", "1"],
499 env=_env(tmp_path),
500 )
501 assert result.exit_code == 0
502 # Context before and after should appear in output.
503 assert "alpha" in result.output
504 assert "gamma" in result.output
505
506
507 def test_context_short_flag(tmp_path: pathlib.Path) -> None:
508 _init_repo(tmp_path)
509 _commit_files(tmp_path, {"ctx2.txt": b"first\nTARGET\nlast\n"})
510 result = _invoke(
511 ["content-grep", "TARGET", "-C", "1"],
512 env=_env(tmp_path),
513 )
514 assert result.exit_code == 0
515 assert "first" in result.output
516 assert "last" in result.output
517
518
519 # ---------------------------------------------------------------------------
520 # Flags: --json boolean (rejects old --format)
521 # ---------------------------------------------------------------------------
522
523
524 def test_format_flag_rejected(tmp_path: pathlib.Path) -> None:
525 """Old ``--format json`` must be rejected by argparse (exit 2)."""
526 _init_repo(tmp_path)
527 _commit_files(tmp_path, {"a.txt": b"hello\n"})
528 result = _invoke(
529 ["content-grep", "hello", "--format", "json"],
530 env=_env(tmp_path),
531 )
532 assert result.exit_code == 2
533
534
535 # ---------------------------------------------------------------------------
536 # Integration: --ref searches a different commit
537 # ---------------------------------------------------------------------------
538
539
540 def test_ref_searches_branch(tmp_path: pathlib.Path) -> None:
541 _init_repo(tmp_path)
542 c1 = _commit_files(tmp_path, {"v1.txt": b"OLD content\n"})
543 _commit_files(tmp_path, {"v2.txt": b"NEW content\n"}, parent_id=c1)
544
545 # Search HEAD — should find NEW in v2.txt.
546 result_head = _invoke(
547 ["content-grep", "NEW", "--json"], env=_env(tmp_path)
548 )
549 assert result_head.exit_code == 0
550 data = _parse(result_head)
551 paths = [r["path"] for r in data["results"]]
552 assert "v2.txt" in paths
553
554 # Search the first commit by ID — should find OLD in v1.txt, not NEW.
555 result_ref = _invoke(
556 ["content-grep", "OLD", "--ref", c1, "--json"],
557 env=_env(tmp_path),
558 )
559 assert result_ref.exit_code == 0
560 data_ref = _parse(result_ref)
561 paths_ref = [r["path"] for r in data_ref["results"]]
562 assert "v1.txt" in paths_ref
563 assert data_ref["commit_id"] == c1
564
565
566 # ---------------------------------------------------------------------------
567 # E2E: --help mentions all new flags
568 # ---------------------------------------------------------------------------
569
570
571 def test_help_mentions_include() -> None:
572 result = _invoke(["content-grep", "--help"])
573 assert result.exit_code == 0
574 assert "--include" in result.output
575
576
577 def test_help_mentions_exclude() -> None:
578 result = _invoke(["content-grep", "--help"])
579 assert "--exclude" in result.output
580
581
582 def test_help_mentions_max_matches() -> None:
583 result = _invoke(["content-grep", "--help"])
584 assert "--max-matches" in result.output
585
586
587 def test_help_mentions_context() -> None:
588 result = _invoke(["content-grep", "--help"])
589 assert "--context" in result.output or "-C" in result.output
590
591
592 def test_help_mentions_json_not_format() -> None:
593 result = _invoke(["content-grep", "--help"])
594 assert "--json" in result.output
595 assert "--format" not in result.output
596
597
598 # ---------------------------------------------------------------------------
599 # Stress: 500-file snapshot, pattern matches 250
600 # ---------------------------------------------------------------------------
601
602
603 def test_stress_500_files(tmp_path: pathlib.Path) -> None:
604 _init_repo(tmp_path)
605 files: _FilesMap = {}
606 for i in range(500):
607 content = b"TARGET_STRESS\n" if i % 2 == 0 else b"other\n"
608 files[f"f_{i:04d}.txt"] = content
609 _commit_files(tmp_path, files)
610 result = _invoke(
611 ["content-grep", "TARGET_STRESS", "--json"],
612 env=_env(tmp_path),
613 )
614 assert result.exit_code == 0
615 data = _parse(result)
616 assert data["total_files_matched"] == 250
617 assert data["total_matches"] == 250
618
619
620 # ---------------------------------------------------------------------------
621 # Stress: concurrent reads
622 # ---------------------------------------------------------------------------
623
624
625 def test_stress_concurrent_reads(tmp_path: pathlib.Path) -> None:
626 _init_repo(tmp_path)
627 _commit_files(tmp_path, {"concurrent.txt": b"CONCURRENT TARGET\n"})
628
629 errors: list[str] = []
630
631 def _read() -> None:
632 r = _invoke(
633 ["content-grep", "CONCURRENT", "--json"],
634 env=_env(tmp_path),
635 )
636 if r.exit_code != 0:
637 errors.append(f"exit {r.exit_code}")
638 else:
639 try:
640 d = json.loads(r.output)
641 if d.get("total_matches", 0) != 1:
642 errors.append(f"unexpected total_matches: {d.get('total_matches')}")
643 except json.JSONDecodeError as exc:
644 errors.append(str(exc))
645
646 threads = [threading.Thread(target=_read) for _ in range(8)]
647 for t in threads:
648 t.start()
649 for t in threads:
650 t.join()
651
652 assert not errors, f"Concurrent read failures: {errors}"
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