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:
+48
-11
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user