diff --git a/booth/items.py b/booth/items.py index 5ad0a38..fbe973e 100644 --- a/booth/items.py +++ b/booth/items.py @@ -283,7 +283,17 @@ def booth_items(booth: Path) -> list[Item]: """ by_rel: dict[str, Path] = {} for p in booth.rglob("*"): - if not p.is_file() or p.name.startswith("."): + try: + # `is_file` swallows a missing entry but PROPAGATES EACCES: a + # directory that lists but cannot be searched made every stat under + # it raise out of here, and `list_booths` calls this for every + # booth — one such folder took down the index for all of them. + # An entry nobody can stat is not a renderable file. + if not p.is_file(): + continue + except OSError: + continue + if p.name.startswith("."): continue if is_ask_file(p.name) or is_answer_file(p.name): continue diff --git a/tests/test_items.py b/tests/test_items.py index af26cb9..d25a586 100644 --- a/tests/test_items.py +++ b/tests/test_items.py @@ -341,6 +341,31 @@ def test_one_unrepresentable_filename_costs_its_own_tile_not_the_booth(tmp_path) assert [it.rel for it in got] == ["ok.png"] + +def test_a_folder_that_lists_but_cannot_be_searched_costs_its_files_not_the_index(tmp_path): + """Found folding R2's bug-hunt: `Path.is_file()` swallows a missing entry + but PROPAGATES EACCES. A directory with read and no execute permission + lists its names, and every stat under it raises — so one such folder in + one booth took out the index for every booth, the same blast radius as the + unrepresentable filename above. Its files are not items. + + Defeating change: calling `is_file()` outside the OSError guard.""" + b = tmp_path / "b" + b.mkdir() + (b / "ok.png").write_bytes(b"\x89PNG") + sub = b / "d" + sub.mkdir() + (sub / "x.png").write_bytes(b"\x89PNG") + sub.chmod(0o644) # r--: listable, nothing inside stat-able + try: + with pytest.raises(PermissionError): + (sub / "x.png").stat() # the fixture is live, not assumed + assert [it.rel for it in booth_items(b)] == ["ok.png"] + [row] = list_booths(tmp_path, ttl_seconds=86400) + assert row["name"] == "b" and row["count"] == 1 + finally: + sub.chmod(0o755) + def test_a_huge_caption_sidecar_is_not_read_whole(tmp_path): """HULDA: `read_text()` pulled the entire sidecar into memory before `[:CAPTION_MAX]` trimmed it, and the handler catches only OSError — so a