From 167f2657c5fc059e79316a8ab52e3405151f8328 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 10:33:33 -0700 Subject: [PATCH] fix(items): an entry the walk cannot stat costs that entry, not every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path.is_file() swallows a missing entry but propagates EACCES. A directory with read and no execute permission lists its names while every stat under it raises, so one such folder in one booth raised out of booth_items — and list_booths calls that for every booth, taking the index down for all of them. The same blast radius as the unrepresentable-filename case; the same posture applies: such an entry is not a renderable file. Predates R2 (identical on main before the merge); found while folding R2's bug-hunt, where it made landed_at's per-entry skip unreachable. --- booth/items.py | 12 +++++++++++- tests/test_items.py | 25 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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