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