fix: four defects the U4 bug-hunt panel found in code it did not add

All four pre-date U4 and sit in files it touched, which is why a diff-scoped
robustness lens saw them. They are separated from the unit's own commit so the
feature history stays readable; the release tags both.

* A booth name reached a JS string context. The confirm dialogs interpolated
  the name into a string literal inside `onsubmit`. Jinja's autoescape is
  HTML-attribute escaping, not JS-string escaping: the browser decodes the
  entity back to a quote before the JS parser sees it, so a name crafted to
  close the string executed on submit. Booth names are agent-authored — making
  a folder under the data dir is the whole API — so this was a live path, not a
  theoretical one. The name now travels as a data attribute to a delegated
  handler, where escaping is escaping.

* An unreadable `links.md` returned 500 for the whole booth page. `is_file()`
  then an unguarded `read_text()`. The board is one tile on that page, and a
  page that will not load is worse than one missing a tile — the posture
  `read_blurred`, `marks_for` and `read_manifest` already take.

* The index order had no tie-breaker, which violates the deterministic-order
  invariant. Equal-mtime booths fell back to whatever `iterdir()` yielded, and
  two booths landed by one `rsync` batch share an mtime exactly. Now
  `(mtime, name)` reverse: newest first, then name. The operator refers to
  cards positionally, so a sequence that moves between renders misfiles his
  judgment rather than crashing.

* `/b/<n>/marks.json` reported damage as empty success. `booth marks` exits 3
  on an unreadable file precisely so a caller can tell "not yet" from "broken";
  the HTTP mirror — the only reader a remote session has — returned the same
  empty list for both. It now carries `error` and `detail`. The status stays
  200 deliberately: reads are lenient here, and a pinned status code is a
  promise to remote clients this fix has no business breaking.

Each has a regression test. 410 tests.
This commit is contained in:
vh
2026-09-22 09:51:14 -07:00
parent c3a97c1b64
commit c75d7a2797
6 changed files with 195 additions and 25 deletions
+48 -11
View File
@@ -487,7 +487,12 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"mtime": mtime,
}
)
booths.sort(key=lambda b: b["mtime"], reverse=True)
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
# booths ordered by whatever `iterdir()` yielded, which is not a rule — and
# invariant 6 is not "usually stable", it is a sentence you can write down.
# Two booths created by one `rsync` batch share an mtime exactly, and the
# operator refers to cards positionally.
booths.sort(key=lambda b: (b["mtime"], b["name"]), reverse=True)
return booths
@@ -914,13 +919,13 @@ def create_app(
# Ordered pinned-first then newest-first, each row stamped with a
# `pinned` flag. Empty list for every other booth, so the template
# branch simply does not fire.
"board": (
order_for_display(
parse_link_entries((booth / LINKS_FILE).read_text()),
read_pins(booth),
)
if (booth / LINKS_FILE).is_file() else []
),
# `is_file()` then an UNGUARDED read was a 500 waiting on a
# mode change or an EIO: the board is one tile on this page, and
# a page that will not load is worse than one missing a tile —
# the same posture `read_blurred`, `marks_for` and
# `read_manifest` already take. A booth whose `links.md` cannot
# be read renders as a booth with no board.
"board": _board_rows(booth),
# Marks: operator judgment attached to this booth or to one of
# its items — a session's question (`pick`), the operator's own
# remark (`note`), the operator's selection (`flag`). Rendered
@@ -947,6 +952,21 @@ def create_app(
},
)
def _board_rows(booth: Path) -> list[dict]:
"""The link board's rows, or [] for a board that cannot be read.
NEVER RAISES, for the reason every other read on this page does not:
one damaged file must cost its own tile, not the booth page."""
try:
if not (booth / LINKS_FILE).is_file():
return []
return order_for_display(
parse_link_entries((booth / LINKS_FILE).read_text()),
read_pins(booth),
)
except (OSError, ValueError, UnicodeDecodeError):
return []
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
"""Land where the form was: the standalone marks page for a verbatim
booth (its own index.html cannot show the recorded judgment), else the
@@ -1197,12 +1217,29 @@ def create_app(
the whole booth instead of one question at a time.
"""
booth = resolve_booth(name)
marks = marks_for(booth)
return JSONResponse({
marks, read_err = hold_read(booth)
body = {
"booth": name,
"marks": [as_dict(m) for m in marks],
"open": [m.id for m in open_marks(marks)],
})
}
# A DAMAGED file used to come back as an empty list and nothing else,
# which is indistinguishable from "you were never asked anything" — and
# this endpoint is the ONLY reader a remote session has. Its filesystem
# sibling has told the truth since U2: `booth marks` exits 3 on an
# unreadable file precisely so a caller can tell "not yet" from
# "broken". One question, two surfaces, two answers.
#
# The STATUS stays 200 and that is deliberate. Reads are lenient here —
# the same rule that keeps a poisoned booth from 500ing the index — and
# a pinned status code is a promise to remote clients this fix has no
# business breaking. The information goes in the body instead: a client
# that wants the CLI's exit-3 parity reads `error`, and one that does
# not behaves exactly as it does today.
if read_err is not None:
body["error"] = "this booth's .marks.json cannot be read"
body["detail"] = read_err
return JSONResponse(body)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):