|
40 | 40 | from mempalace.llm_refine import collect_corpus_text |
41 | 41 | from mempalace.miner import _read_text_no_follow, load_config, mine, scan_project |
42 | 42 | from mempalace.normalize import _read_transcript_file |
43 | | -from mempalace.project_scanner import _collect_manifest_names |
| 43 | +from mempalace.project_scanner import _collect_manifest_names, _parse_gradle |
44 | 44 | from mempalace.repair import _copy_file_no_follow, _open_regular_file_no_follow |
45 | 45 | from mempalace.room_detector_local import detect_rooms_local |
46 | 46 | from mempalace.split_mega_files import main as split_main |
| 47 | +from mempalace.split_mega_files import split_file |
47 | 48 | from mempalace.sweeper import parse_claude_jsonl, sweep_directory |
48 | 49 |
|
49 | 50 | # ``os.mkfifo`` and ``SIGALRM`` are both POSIX-only. Windows has no FIFO in |
|
54 | 55 | reason="requires POSIX FIFOs and SIGALRM", |
55 | 56 | ) |
56 | 57 |
|
| 58 | +# Root holds CAP_DAC_OVERRIDE and walks straight into a directory with no |
| 59 | +# ``x`` bit, so the file each test walls off stays readable and the assertion |
| 60 | +# below breaks: the state these tests need cannot be built as root, they do |
| 61 | +# not merely pass vacuously there. ``tests/test_backups.py`` gates the same |
| 62 | +# way and additionally excludes Windows, which it has to because it carries |
| 63 | +# no ``posix_only``; every use here already sits under ``posix_only``. |
| 64 | +needs_unprivileged_posix = pytest.mark.skipif( |
| 65 | + hasattr(os, "geteuid") and os.geteuid() == 0, |
| 66 | + reason="directory permission bits do not gate root", |
| 67 | +) |
| 68 | + |
57 | 69 | TIMEOUT_SECONDS = 10.0 |
58 | 70 |
|
59 | 71 |
|
@@ -356,6 +368,104 @@ def test_split_mega_files_skips_fifo(tmp_path, capsys, monkeypatch): |
356 | 368 | assert "real.txt" in out |
357 | 369 |
|
358 | 370 |
|
| 371 | +@posix_only |
| 372 | +def test_split_file_skips_a_fifo_at_its_own_output_name(tmp_path, capsys): |
| 373 | + """The walk gate covers the source; the output name is built here. |
| 374 | +
|
| 375 | + ``split_file`` synthesises each per-session filename from the transcript |
| 376 | + and writes it into the source directory, so nothing has vetted that path. |
| 377 | + A pre-existing FIFO sitting at one of those names turned the write into a |
| 378 | + blocking open — the same hang, in the one path the discovery gate cannot |
| 379 | + reach. |
| 380 | + """ |
| 381 | + session = "Claude Code v1.0\n" + "content line\n" * 14 + "\n" * 5 |
| 382 | + source = write_regular(tmp_path, "real.txt", session * 2) |
| 383 | + planned = split_file(str(source), None, dry_run=True) |
| 384 | + assert len(planned) >= 2, "fixture must produce at least two output files" |
| 385 | + blocked = Path(planned[0]) |
| 386 | + os.mkfifo(blocked) |
| 387 | + |
| 388 | + with hard_timeout(TIMEOUT_SECONDS, "split_file writing over a FIFO output"): |
| 389 | + written = split_file(str(source), None, dry_run=False) |
| 390 | + |
| 391 | + out = capsys.readouterr().out |
| 392 | + assert f"SKIP: {blocked.name} (not a regular file)" in out |
| 393 | + assert blocked not in written |
| 394 | + # The pipe must cost only its own chunk: every other session still lands. |
| 395 | + assert len(written) == len(planned) - 1 |
| 396 | + assert all(path.is_file() for path in written) |
| 397 | + |
| 398 | + |
| 399 | +@posix_only |
| 400 | +def test_split_file_skips_a_dangling_symlink_at_its_own_output_name(tmp_path, capsys): |
| 401 | + """A broken link at an output name must not redirect the write. |
| 402 | +
|
| 403 | + ``os.path.exists`` follows the link and answers False for a dangling one, |
| 404 | + so the type gate would wave it through — and ``write_text`` then CREATES |
| 405 | + the target, landing a chunk wherever the link points instead of in the |
| 406 | + output directory. The gate has to ask about the link itself. |
| 407 | + """ |
| 408 | + session = "Claude Code v1.0\n" + "content line\n" * 14 + "\n" * 5 |
| 409 | + source = write_regular(tmp_path, "real.txt", session * 2) |
| 410 | + planned = split_file(str(source), None, dry_run=True) |
| 411 | + assert len(planned) >= 2, "fixture must produce at least two output files" |
| 412 | + blocked = Path(planned[0]) |
| 413 | + outside = tmp_path / "outside" / "victim.txt" |
| 414 | + outside.parent.mkdir() |
| 415 | + os.symlink(outside, blocked) |
| 416 | + assert not outside.exists(), "the link must dangle before the run" |
| 417 | + |
| 418 | + with hard_timeout(TIMEOUT_SECONDS, "split_file writing over a dangling symlink"): |
| 419 | + written = split_file(str(source), None, dry_run=False) |
| 420 | + |
| 421 | + out = capsys.readouterr().out |
| 422 | + assert f"SKIP: {blocked.name} (not a regular file)" in out |
| 423 | + assert blocked not in written |
| 424 | + assert not outside.exists(), "a chunk was written through the link, outside the output dir" |
| 425 | + assert len(written) == len(planned) - 1 |
| 426 | + |
| 427 | + |
| 428 | +@posix_only |
| 429 | +@needs_unprivileged_posix |
| 430 | +def test_collect_manifest_names_survives_an_unreadable_directory(tmp_path): |
| 431 | + """The type gate must not turn a skipped manifest into a crash. |
| 432 | +
|
| 433 | + ``os.walk`` lists the children of a directory with ``r`` but no ``x``, |
| 434 | + and stating one of them raises ``PermissionError``. Each parser already |
| 435 | + swallowed that through its own ``except OSError``, so the gate in front |
| 436 | + of them has to swallow it too — otherwise ``mempalace init`` gains a |
| 437 | + traceback where it used to report no manifest name. |
| 438 | + """ |
| 439 | + repo = tmp_path / "repo" |
| 440 | + repo.mkdir() |
| 441 | + (repo / "package.json").write_text('{"name": "inner"}', encoding="utf-8") |
| 442 | + os.chmod(repo, 0o444) |
| 443 | + try: |
| 444 | + with hard_timeout(TIMEOUT_SECONDS, "_collect_manifest_names over an unreadable dir"): |
| 445 | + found = _collect_manifest_names(repo) |
| 446 | + finally: |
| 447 | + os.chmod(repo, 0o755) |
| 448 | + assert found == [] |
| 449 | + |
| 450 | + |
| 451 | +@posix_only |
| 452 | +@needs_unprivileged_posix |
| 453 | +def test_parse_gradle_survives_an_unreadable_directory(tmp_path): |
| 454 | + """The sibling ``settings.gradle`` is stat'd inside the parser's own try.""" |
| 455 | + repo = tmp_path / "repo" |
| 456 | + repo.mkdir() |
| 457 | + build = repo / "build.gradle" |
| 458 | + build.write_text("plugins { id 'java' }\n", encoding="utf-8") |
| 459 | + os.chmod(repo, 0o444) |
| 460 | + try: |
| 461 | + with hard_timeout(TIMEOUT_SECONDS, "_parse_gradle over an unreadable dir"): |
| 462 | + name = _parse_gradle(build) |
| 463 | + finally: |
| 464 | + os.chmod(repo, 0o755) |
| 465 | + # Falls back to the directory name, exactly as it did before the gate. |
| 466 | + assert name == "repo" |
| 467 | + |
| 468 | + |
359 | 469 | @posix_only |
360 | 470 | def test_format_miner_extract_text_does_not_block_on_fifo(tmp_path): |
361 | 471 | """``mine --mode extract`` was already immune — its zero-size gate fires |
@@ -584,6 +694,34 @@ def test_sweep_directory_skips_a_fifo_without_booking_a_failure(tmp_path, capsys |
584 | 694 | assert "SKIP: piped.jsonl (not a regular file)" in capsys.readouterr().err |
585 | 695 |
|
586 | 696 |
|
| 697 | +@posix_only |
| 698 | +def test_sweep_directory_still_books_a_stat_failure_as_a_failure(tmp_path, capsys): |
| 699 | + """A pipe is nothing to sweep; a stat that FAILS is a real error. |
| 700 | +
|
| 701 | + The type gate has to tell those apart. A dangling symlink, a symlink loop |
| 702 | + and a file unlinked between ``rglob`` and the gate all raise from |
| 703 | + ``stat`` — and every one of them used to reach ``open`` inside ``sweep`` |
| 704 | + and be booked. Swallowing them would flip ``mempalace sweep`` from exit 2 |
| 705 | + to exit 0 on a transcript it could not read. |
| 706 | + """ |
| 707 | + convos = tmp_path / "convos" |
| 708 | + convos.mkdir() |
| 709 | + write_regular( |
| 710 | + convos, |
| 711 | + "real.jsonl", |
| 712 | + '{"type": "user", "sessionId": "s1", "uuid": "u1", ' |
| 713 | + '"timestamp": "2026-01-01T00:00:00Z", ' |
| 714 | + '"message": {"role": "user", "content": "hello"}}\n', |
| 715 | + ) |
| 716 | + os.symlink(convos / "gone.jsonl", convos / "dangling.jsonl") |
| 717 | + with hard_timeout(TIMEOUT_SECONDS, "sweep_directory over a dangling symlink"): |
| 718 | + result = sweep_directory(str(convos), str(tmp_path / "palace")) |
| 719 | + # ``cli.cmd_sweep`` turns a non-empty ``failures`` into ``sys.exit(2)``. |
| 720 | + assert [Path(entry["file"]).name for entry in result["failures"]] == ["dangling.jsonl"] |
| 721 | + assert result["files_succeeded"] == 1 |
| 722 | + assert "stat failed" in capsys.readouterr().err |
| 723 | + |
| 724 | + |
587 | 725 | # ───────────────────────────────────────────────────────────────────────── |
588 | 726 | # O_NONBLOCK must not drop a regular file the blocking open would have read |
589 | 727 | # ───────────────────────────────────────────────────────────────────────── |
@@ -645,6 +783,7 @@ def _fake_open(path, flags, *args, **kwargs): |
645 | 783 |
|
646 | 784 |
|
647 | 785 | @posix_only |
| 786 | +@needs_unprivileged_posix |
648 | 787 | def test_gather_origin_samples_survives_an_unreadable_directory(tmp_path): |
649 | 788 | """The type gate must not turn a skipped file into a crash. |
650 | 789 |
|
|
0 commit comments