From 167f2657c5fc059e79316a8ab52e3405151f8328 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 10:33:33 -0700 Subject: [PATCH 1/2] 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 From 39a3cb2262dc349aa73fadcd05642b36505ea232 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 23 Sep 2026 10:33:33 -0700 Subject: [PATCH 2/2] test(r2): commit the round's falsifiers as a mutation table; one flag predicate tests/mutations/r2_flow.toml: 18 falsifiers, each proved RED under its change by scripts/mutation_check.py (18/18). Its first run found three vacuous proofs, now resolved: - landed_at's per-entry skip: the symlink-loop fixture stopped raising once the clock moved to lstat. New fixture: a folder that lists but cannot be searched. - the Desk's bench URL guard: the test covered bookmarks only. A hand-edited registry bench now rides with it. - flagged_targets' `error is None`: defence in depth (hydration already strips a damaged mark's target), so no single-guard row; named in the table header instead. The rail's flagged filter and the orphan-flag list read flagged_targets rather than restating it; no reachable behaviour changes. --- booth/app.py | 7 +- docs/contracts/r2_flow.contract.md | 5 +- tests/mutations/r2_flow.toml | 160 +++++++++++++++++++++++++++++ tests/test_flow.py | 36 +++++++ 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 tests/mutations/r2_flow.toml diff --git a/booth/app.py b/booth/app.py index f130d57..1d3d24a 100644 --- a/booth/app.py +++ b/booth/app.py @@ -1206,8 +1206,8 @@ def create_app( # no tray slot, so it is listed apart with its withdraw control # rather than vanishing from the page while staying in the file. "orphan_flags": [m for m in marks - if m.shape == "flag" and m.error is None and m.target - and m.target not in {it["name"] for it in gallery}], + if m.shape == "flag" and m.target in + flagged_targets(marks) - {it["name"] for it in gallery}], "ord_width": len(str(len(gallery))), "uploaded": (booth / UPLOAD_MARKER).exists(), # The same provenance line the index card carries. Deliberate: @@ -1238,11 +1238,12 @@ def create_app( """ active = requested if requested in FILTERS else "all" open_ids = {m.id for m in open_marks(marks)} + flagged = flagged_targets(marks) # THE flag predicate (R2) buckets: dict[str, list[dict]] = {f: [] for f in FILTERS} for it in gallery: mine = marks_for_target(marks, it["name"]) buckets["all"].append(it) - if any(m.shape == "flag" and m.flagged for m in mine): + if it["name"] in flagged: buckets["flagged"].append(it) if any(m.shape == "note" for m in mine): buckets["annotated"].append(it) diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index 2ee041c..5a7d995 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -247,8 +247,9 @@ rule — a second renderer in JavaScript would be the same bug in a new language - **`flags`**: the number of CURRENT items carrying a READABLE flag mark, shown on every Desk row that has any — `flagged_targets(marks)` intersected with the booth's item rels. `flagged_targets(marks)` is the ONE flag - predicate. The Desk, the tray, the filmstrip, the tape and the review button - all read it, and an unreadable flag entry counts nowhere. A flag whose file + predicate. The Desk, the tray, the orphan list, the rail's `flagged` filter, + the tiles, the filmstrip, the tape and the review button all read it, and an + unreadable flag entry counts nowhere. A flag whose file has since been deleted is an ORPHAN: it counts on no Desk row, and the tray lists it (C5) so it can be cleared. - **`landed_at`**: the newest mtime among the booth's CONTENT — its regular diff --git a/tests/mutations/r2_flow.toml b/tests/mutations/r2_flow.toml new file mode 100644 index 0000000..c6176f5 --- /dev/null +++ b/tests/mutations/r2_flow.toml @@ -0,0 +1,160 @@ +# R2 — the review flow: falsifiers the round claims, and the change each forbids. +# +# Every row was proved RED under its mutation in the session that wrote it, +# then committed here so the proof is an artifact rather than scrollback. The +# browser rows need the Playwright Chromium the browser tests already use. +# +# Deliberately ABSENT: single guards inside a defence in depth, each of which +# stays green when removed alone because another layer still holds — so a row +# for any one of them would be a vacuous proof, and this table's own first run +# said so. `.seen`'s O_NOFOLLOW, O_NONBLOCK and S_ISREG (the FIFO/symlink test +# covers them together); and `flagged_targets`' `error is None`, since +# hydration already strips the target from a damaged mark. +# +# The serialization row is only a falsifier because its test HOLDS the first +# refresh in the client: localhost alone never lost the race, and the first +# draft of that test stayed green with serialization deleted. + +unit = "the Desk, the lightbox, the review, and the in-place client" + +[[mutation]] +label = 'C1 ordinals count from 0, not 1' +file = "booth/items.py" +test = "tests/test_flow.py::test_a_filtered_tile_keeps_its_number_in_the_whole_set" +old = '''ordinal=len(items) + 1,''' +new = '''ordinal=len(items),''' + +[[mutation]] +label = 'C2 .seen: a nested-too-deep marker escapes the never-raises read' +file = "booth/items.py" +test = "tests/test_flow.py::test_a_deeply_nested_seen_marker_reads_as_nothing_seen" +old = '''except (UnicodeDecodeError, ValueError, RecursionError):''' +new = '''except (UnicodeDecodeError, ValueError):''' + +[[mutation]] +label = 'C3 a non-finite q is accepted as a q-value' +file = "booth/app.py" +test = "tests/test_flow.py::test_a_non_finite_q_is_malformed" +old = ''' raise ValueError("non-finite q")''' +new = ''' pass''' + +[[mutation]] +label = 'C3 204 on an explicit JSON Accept becomes the 303' +file = "booth/app.py" +test = "tests/test_flow.py::test_an_explicit_json_accept_gets_204_and_the_write_still_lands" +old = ''' return Response(status_code=204)''' +new = ''' pass''' + +[[mutation]] +label = 'C3 back=view lands on the review for a doc too (ring check dropped)' +file = "booth/app.py" +test = "tests/test_flow.py::test_back_view_lands_on_the_review_only_for_a_media_item" +old = ''' if f in ring:''' +new = ''' if True:''' + +[[mutation]] +label = 'C4 the Desk counts orphan flags' +file = "booth/app.py" +test = "tests/test_flow.py::test_a_flag_on_a_file_that_is_gone_stays_visible_and_withdrawable" +old = '''"flags": len(flagged_targets(marks) & {it.rel for it in items}),''' +new = '''"flags": len(flagged_targets(marks)),''' + +[[mutation]] +label = 'C4 landed_at follows symlinks' +file = "booth/app.py" +test = "tests/test_flow.py::test_the_content_clock_reads_the_booth_not_what_its_links_point_at" +old = ''' st = p.lstat()''' +new = ''' st = p.stat()''' + +[[mutation]] +label = 'C4 one unreadable entry reads the whole booth as landed NOW' +file = "booth/app.py" +test = "tests/test_flow.py::test_one_unreadable_entry_costs_that_entry_not_the_booth" +old = '''pin it in "new" forever. + continue''' +new = '''pin it in "new" forever. + return time.time()''' + +[[mutation]] +label = 'C4 a non-web bookmark URL becomes a link' +file = "booth/templates/index.html" +test = "tests/test_flow.py::test_the_desk_never_makes_a_non_web_url_clickable" +old = '''{% set web = e.url.lower().startswith(('http://', 'https://')) %}''' +new = '''{% set web = true %}''' + +[[mutation]] +label = 'C4 a non-web bench URL becomes a link' +file = "booth/templates/index.html" +test = "tests/test_flow.py::test_the_desk_never_makes_a_non_web_url_clickable" +old = '''{% set web = b.url.lower().startswith(('http://', 'https://')) %}''' +new = '''{% set web = true %}''' + +[[mutation]] +label = 'C5 audio/video tiles lose their review link' +file = "booth/templates/booth.html" +test = "tests/test_flow.py::test_a_sound_only_booth_can_open_the_review" +old = '''{% if it.kind in ('video', 'audio') %}''' +new = '''
''' + +[[mutation]] +label = "C6 the next arrow's rail offset applies at phone width" +file = "booth/templates/view.html" +test = "tests/test_flow_browser.py::test_the_next_arrow_clears_the_rail_only_beside_it" +old = ''' .vprev{left:0}.vnext{right:0} + @media (min-width:901px){.vnext{right:360px}} +''' +new = ''' .vprev{left:0}.vnext{right:360px} +''' + +[[mutation]] +label = "resolver: an entry that cannot be stat'd raises out of booth_items" +file = "booth/items.py" +test = "tests/test_items.py::test_a_folder_that_lists_but_cannot_be_searched_costs_its_files_not_the_index" +old = ''' + if not p.is_file(): + continue + except OSError: + continue''' +new = ''' + if not p.is_file(): + continue + except FileNotFoundError: + continue''' diff --git a/tests/test_flow.py b/tests/test_flow.py index df14bf0..5454baa 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -759,6 +759,33 @@ def test_the_content_clock_reads_the_booth_not_what_its_links_point_at(tmp_path) assert _desk(c.get("/").text).get("rest") == ["g"] + +def test_one_unreadable_entry_costs_that_entry_not_the_booth(tmp_path): + """Nyx (groa): one entry the walk can list but not stat made the whole + booth read as landed NOW on every load, pinned in 'new' forever. A + directory readable but not searchable is that entry: its names list, and + every lstat under it is EACCES. (The symlink loop that first showed this + stopped being a fixture for it once the clock moved to lstat, which reads + a loop without following it.)""" + import os + t0 = time.time() - 10_000 + b = _booth(tmp_path, "g", {"a.png": PNG}) + sub = b / "d" + sub.mkdir() + (sub / "x.png").write_bytes(PNG) + for p in (b / "a.png", sub / "x.png", sub): + _at(p, t0) + _at(b, t0) + c = _client(tmp_path) + c.get("/b/g/") # look at it + sub.chmod(0o644) # r--: listable, nothing inside stat-able + try: + with pytest.raises(PermissionError): + (sub / "x.png").lstat() # the fixture is live, not assumed + assert _desk(c.get("/").text).get("rest") == ["g"] + finally: + sub.chmod(0o755) + def test_a_nul_in_the_review_path_is_a_404_not_a_500(tmp_path): """Nyx (groa, seat-probed): Path raises ValueError on an embedded NUL, and the route caught only OSError. Every other hostile `f` is a 404.""" @@ -789,11 +816,20 @@ def test_the_desk_never_makes_a_non_web_url_clickable(tmp_path): """Nyx (kimi): bookmark and bench URLs are agent-written and land in href. Autoescape does nothing about a `javascript:` scheme. The Desk links only http(s) and shows anything else as plain text.""" + import json rows = (_link("evil", "javascript:alert`1`") + _link("fine", "https://example.test/")) board = _booth(tmp_path, "links", {"links.md": rows.encode()}) (board / ".forever").write_bytes(b"") + # The bench WRITE path refuses a non-web URL; a hand-edited registry does + # not pass through it, and the reader takes any text. + (tmp_path / ".benches.json").write_text(json.dumps({ + "evil": {"url": "javascript:alert(1)", "name": "evil bench"}, + "http://h:1/": {"url": "http://h:1/", "name": "fine bench"}})) body = _client(tmp_path).get("/").text panel = re.search(r'data-panel="bookmarks".*?', body, re.S).group(0) assert 'href="javascript:' not in panel assert 'href="https://example.test/"' in panel and "evil" in panel + benches = re.search(r'data-panel="benches".*?', body, re.S).group(0) + assert 'href="javascript:' not in benches + assert 'href="http://h:1/"' in benches and "evil bench" in benches