merge(r2 follow-up): the EACCES blast radius, and r2's falsifier table
design-dev's two follow-up commits on the R2 branch.167f265is PRE-EXISTING and his to have found, not his to have caused: Path.is_file() swallows ENOENT but PROPAGATES EACCES, so one folder with r-- and no x in one booth made booth_items raise — and list_booths calls it for every booth, so the index 500s for all of them. Identical blast radius to the 0xff filename the bug-hunt panel found, arriving through a different syscall.39a3cb2commits R2's own falsifiers as tests/mutations/r2_flow.toml, 18 rows. Its first run caught three vacuous proofs, which is the fourth time this week that running the mutation has disagreed with reading the assertion. # Conflicts: # booth/items.py
This commit is contained in:
+4
-3
@@ -1229,8 +1229,8 @@ def create_app(
|
|||||||
# no tray slot, so it is listed apart with its withdraw control
|
# no tray slot, so it is listed apart with its withdraw control
|
||||||
# rather than vanishing from the page while staying in the file.
|
# rather than vanishing from the page while staying in the file.
|
||||||
"orphan_flags": [m for m in marks
|
"orphan_flags": [m for m in marks
|
||||||
if m.shape == "flag" and m.error is None and m.target
|
if m.shape == "flag" and m.target in
|
||||||
and m.target not in {it["name"] for it in gallery}],
|
flagged_targets(marks) - {it["name"] for it in gallery}],
|
||||||
"ord_width": len(str(len(gallery))),
|
"ord_width": len(str(len(gallery))),
|
||||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||||
# The same provenance line the index card carries. Deliberate:
|
# The same provenance line the index card carries. Deliberate:
|
||||||
@@ -1261,11 +1261,12 @@ def create_app(
|
|||||||
"""
|
"""
|
||||||
active = requested if requested in FILTERS else "all"
|
active = requested if requested in FILTERS else "all"
|
||||||
open_ids = {m.id for m in open_marks(marks)}
|
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}
|
buckets: dict[str, list[dict]] = {f: [] for f in FILTERS}
|
||||||
for it in gallery:
|
for it in gallery:
|
||||||
mine = marks_for_target(marks, it["name"])
|
mine = marks_for_target(marks, it["name"])
|
||||||
buckets["all"].append(it)
|
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)
|
buckets["flagged"].append(it)
|
||||||
if any(m.shape == "note" for m in mine):
|
if any(m.shape == "note" for m in mine):
|
||||||
buckets["annotated"].append(it)
|
buckets["annotated"].append(it)
|
||||||
|
|||||||
+16
-1
@@ -289,12 +289,27 @@ def booth_items(booth: Path) -> list[Item]:
|
|||||||
"""
|
"""
|
||||||
by_rel: dict[str, Path] = {}
|
by_rel: dict[str, Path] = {}
|
||||||
for p in booth.rglob("*"):
|
for p in booth.rglob("*"):
|
||||||
|
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. (design-dev)
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
# ⚠ EVERY path component, not just the filename. `p.name.startswith(".")`
|
# ⚠ EVERY path component, not just the filename. `p.name.startswith(".")`
|
||||||
# tested only the leaf, so `.thumbs/a.png` (name `a.png`) sailed through
|
# tested only the leaf, so `.thumbs/a.png` (name `a.png`) sailed through
|
||||||
# as a gallery item — and CLAUDE.md invariant 2 promises a dotfile costs
|
# as a gallery item — and CLAUDE.md invariant 2 promises a dotfile costs
|
||||||
# nothing in item counts, galleries or zips. That promise was true only
|
# nothing in item counts, galleries or zips. That promise was true only
|
||||||
# at the top level until the `.thumbs/` cache made it matter.
|
# at the top level until the `.thumbs/` cache made it matter.
|
||||||
if not p.is_file() or any(part.startswith(".") for part in p.relative_to(booth).parts):
|
#
|
||||||
|
# BOTH guards, not either: they were written independently for different
|
||||||
|
# failures and the merge that kept one would have quietly dropped the
|
||||||
|
# other.
|
||||||
|
if any(part.startswith(".") for part in p.relative_to(booth).parts):
|
||||||
|
continue
|
||||||
continue
|
continue
|
||||||
if is_ask_file(p.name) or is_answer_file(p.name):
|
if is_ask_file(p.name) or is_answer_file(p.name):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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,
|
- **`flags`**: the number of CURRENT items carrying a READABLE flag mark,
|
||||||
shown on every Desk row that has any — `flagged_targets(marks)` intersected
|
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
|
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
|
predicate. The Desk, the tray, the orphan list, the rail's `flagged` filter,
|
||||||
all read it, and an unreadable flag entry counts nowhere. A flag whose file
|
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
|
has since been deleted is an ORPHAN: it counts on no Desk row, and the tray
|
||||||
lists it (C5) so it can be cleared.
|
lists it (C5) so it can be cleared.
|
||||||
- **`landed_at`**: the newest mtime among the booth's CONTENT — its regular
|
- **`landed_at`**: the newest mtime among the booth's CONTENT — its regular
|
||||||
|
|||||||
@@ -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') %}<a class="rv-link"'''
|
||||||
|
new = '''{% if false %}<a class="rv-link"'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = 'C6 a NUL in ?f escapes as a 500'
|
||||||
|
file = "booth/app.py"
|
||||||
|
test = "tests/test_flow.py::test_a_nul_in_the_review_path_is_a_404_not_a_500"
|
||||||
|
old = ''' except (OSError, ValueError):
|
||||||
|
# ValueError: an embedded NUL.'''
|
||||||
|
new = ''' except OSError:
|
||||||
|
# ValueError: an embedded NUL.'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = 'C3 client: no busy guard (a double-click writes twice)'
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once"
|
||||||
|
old = ''' if (form.__busy) return;
|
||||||
|
'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = 'C3 client: an unsent radio is not carried across a swap'
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once"
|
||||||
|
old = ''' if (el.checked !== el.defaultChecked) t.checked = el.checked;'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = 'C3 client: saves are not serialized'
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_quick_successive_flags_all_show"
|
||||||
|
old = ''' queue = queue.then(function () { return run(form, data); })'''
|
||||||
|
new = ''' queue = run(form, data)'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = 'C3 the standalone marks page has no region'
|
||||||
|
file = "booth/templates/marks.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_the_standalone_marks_page_updates_in_place"
|
||||||
|
old = '''<div class="marks-panel" data-region="marks-panel">'''
|
||||||
|
new = '''<div class="marks-panel">'''
|
||||||
|
|
||||||
|
[[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'''
|
||||||
@@ -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"]
|
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):
|
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
|
"""Nyx (groa, seat-probed): Path raises ValueError on an embedded NUL, and
|
||||||
the route caught only OSError. Every other hostile `f` is a 404."""
|
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.
|
"""Nyx (kimi): bookmark and bench URLs are agent-written and land in href.
|
||||||
Autoescape does nothing about a `javascript:` scheme. The Desk links only
|
Autoescape does nothing about a `javascript:` scheme. The Desk links only
|
||||||
http(s) and shows anything else as plain text."""
|
http(s) and shows anything else as plain text."""
|
||||||
|
import json
|
||||||
rows = (_link("evil", "javascript:alert`1`")
|
rows = (_link("evil", "javascript:alert`1`")
|
||||||
+ _link("fine", "https://example.test/"))
|
+ _link("fine", "https://example.test/"))
|
||||||
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
|
board = _booth(tmp_path, "links", {"links.md": rows.encode()})
|
||||||
(board / ".forever").write_bytes(b"")
|
(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
|
body = _client(tmp_path).get("/").text
|
||||||
panel = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
|
panel = re.search(r'data-panel="bookmarks".*?</section>', body, re.S).group(0)
|
||||||
assert 'href="javascript:' not in panel
|
assert 'href="javascript:' not in panel
|
||||||
assert 'href="https://example.test/"' in panel and "evil" in panel
|
assert 'href="https://example.test/"' in panel and "evil" in panel
|
||||||
|
benches = re.search(r'data-panel="benches".*?</section>', body, re.S).group(0)
|
||||||
|
assert 'href="javascript:' not in benches
|
||||||
|
assert 'href="http://h:1/"' in benches and "evil bench" in benches
|
||||||
|
|||||||
@@ -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"]
|
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):
|
def test_a_huge_caption_sidecar_is_not_read_whole(tmp_path):
|
||||||
"""HULDA: `read_text()` pulled the entire sidecar into memory before
|
"""HULDA: `read_text()` pulled the entire sidecar into memory before
|
||||||
`[:CAPTION_MAX]` trimmed it, and the handler catches only OSError — so a
|
`[:CAPTION_MAX]` trimmed it, and the handler catches only OSError — so a
|
||||||
|
|||||||
Reference in New Issue
Block a user