fix(manifest): fold in both cross-frontier panels — and a live hole in v0.2.2

Two four-arm artifact-only rounds landed together: the contract paraphrase
(against the pre-seam-review capture) and the code-vs-contract conformance
review (against the amended one), correctly firewalled from each other.
The conformance round found ZERO drift in the strict sense — the code is a
clause-for-clause implementation of the contract — and the weight of both
rounds landed one layer down, in what green tests structurally cannot
report. Full triage in persistent-memory.d/.

A LIVE HOLE IN RELEASED CODE, FOUND ON THE SIBLING MODULE

v0.2.2 adopted the RecursionError finding from the bug-hunt round and
closed half of it: `_hydrate_safe` guards hydration, but `json.loads` runs
above it in `_read_raw`, whose catch list covers neither RecursionError nor
MemoryError. A 400 KB file of nothing but brackets in any ONE booth
therefore still returned 500 for `/` and `/healthz` across every booth on
the service. Confirmed by running it before believing it.

Both modules now bound the read by `stat` before touching the bytes and
catch both classes anyway, so raising a bound later cannot quietly re-open
the hole. The strict half of the marks asymmetry refuses everything the
lenient half tolerates, or a file that reads as "no marks" gets replaced by
a write that believed it.

THE WHY-WIPE

`booth new x --why "..."` then `booth add x out/*.png` erased the sentence
the first command existed to record. Omitted flags meant empty strings and
empty strings overwrote. Two arms predicted it from the contract's wording
alone; every test here passed --why on both calls and so could not see it.
Omitted now means unchanged and an explicit --why "" still clears — the
shell carries the distinction by leaving the variable UNSET, not empty.

--title WAS WRITE-ONLY

Stored, flag-surfaced, rendered nowhere. 4/4, and independently top-ranked
by every arm of the paraphrase round. It lands on the booth page heading
with the directory name beside it, because the directory name is the
identity the operator navigates by and refers to positionally.

THREE TESTS THAT COULD NOT FAIL

- test_the_write_is_atomic asserted no *.tmp survived, which a plain
  write_text passes. It asserts the inode changes now. (The first
  replacement was ALSO vacuous — it spied on os.open, which Path.write_text
  reaches through io.open in C and never touches. Recorded in the test,
  because writing a second vacuous test while fixing the first is exactly
  the failure this round is about.)
- The INV-3 preservation test passed against an implementation that
  regenerated `created` every time, because _now() is whole-second
  resolution and back-to-back writes share a stamp. Seeded from 2019 now.
- test_announcing_is_activity passed whether or not _newest_mtime counted
  the manifest, because writing it bumps the directory mtime either way.
  The directory's clock is put back, leaving the file as the only thing
  that can keep the booth alive.

ALSO

- The title fallback skipped the normalizer the explicit value gets; a
  directory name may legally carry a newline and run to 255 bytes.
- Every writer derived the same .booth.json.tmp. Marks are protected from
  that by their flock; the manifest has none, so uniqueness stands in.
- test_stdlib_only was blind to relative imports in all four modules.
- INV-1 had no guard at all; INV-5 named two different promises; the
  negative render states were asserted on the index only.

Contract amended throughout: the 4 GB case is a stat-checked bound rather
than a return constraint, every field of an error-carrying record has a
stated value, INV-1 no longer contradicts INV-3, repo-wide rules are named
in words instead of by a colliding number, and touches admits the macro
partial the implementation added.

329 tests.
This commit is contained in:
Vuong Hoang
2026-09-22 01:29:27 -07:00
parent fac83de8f4
commit c015a917ee
9 changed files with 612 additions and 93 deletions
+55 -2
View File
@@ -291,8 +291,17 @@ def test_stdlib_only(module):
for node in ast.walk(tree):
if isinstance(node, ast.Import):
roots.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
roots.add(node.module.split(".")[0])
elif isinstance(node, ast.ImportFrom):
# `node.level > 0` is a RELATIVE import (`from . import marks`),
# which has no `module` root to inspect and used to slip through
# this walk entirely. It cannot reach outside the package, so it is
# stdlib-safe by construction — but it is recorded rather than
# ignored, because `manifest.py` additionally forbids importing a
# sibling and its own test needs to see one.
if node.level:
roots.add("booth")
elif node.module:
roots.add(node.module.split(".")[0])
outside = {r for r in roots if r != "booth" and r not in sys.stdlib_module_names}
assert not outside, f"booth/{module}.py imports non-stdlib: {sorted(outside)}"
@@ -1194,3 +1203,47 @@ def test_an_unreadable_mark_is_visible_on_the_page(client):
html = c.get("/b/b/").text
assert "⚠ broken" in html, "an unreadable mark rendered as an empty note"
assert "n1" in html
def test_a_marks_file_no_one_can_parse_does_not_take_down_the_index(tmp_path):
"""The v0.2.2 round adopted the RecursionError finding and closed only half
of it. `_hydrate_safe` guards hydration; `json.loads` runs BEFORE that, in
`_read_raw`, whose `except (OSError, ValueError, UnicodeDecodeError)` does
not cover RecursionError or MemoryError.
So a 400 KB file of nothing but brackets, in any one booth, still returned
500 for `/` and `/healthz` across every booth on the service. Found by the
U5 code-review panel against the sibling module and confirmed by running it.
The read is bounded now and both classes are caught.
"""
booth = tmp_path / "b"
booth.mkdir()
(booth / MARKS_FILE).write_text("[" * 200_000 + "]" * 200_000)
assert marks_for(booth) == []
def test_a_marks_file_too_large_to_be_marks_is_refused_before_it_is_read(tmp_path):
"""Bounded by `stat`, not survived. A booth holds one marks document, and
the index reads every booth's on every page load."""
from booth.marks import MARKS_MAX_BYTES
booth = tmp_path / "b"
booth.mkdir()
(booth / MARKS_FILE).write_text(" " * (MARKS_MAX_BYTES + 10))
assert marks_for(booth) == []
def test_a_write_over_an_unparseable_marks_file_still_refuses(tmp_path):
"""The strict half of the asymmetry has to see the same failures the lenient
half does, or a file that reads as "no marks" gets replaced by a write that
believed it. Same two exception classes, same bound."""
from booth.marks import MarksCorrupt, set_flag
booth = tmp_path / "b"
booth.mkdir()
(booth / MARKS_FILE).write_text("[" * 200_000 + "]" * 200_000)
with pytest.raises(MarksCorrupt):
set_flag(booth, "a.png", True)