fix(blur): .blurred round-trips any rel, and one writer serves both surfaces
The heid bug-hunt on r2b merge 1 found the /blur route stripping `f` before
writing, so the form for " a.png" blurred its neighbour "a.png". The route was
only half of it: `.blurred` was one stripped rel per line, so no writer could
store a rel with a leading space or a newline, whatever the route did.
Operator-ruled 2026-09-23 ("fix the blur").
- booth/blur.py (new, stdlib-only): read_blurred / set_blurred / BLUR_FILE.
`.blurred` is now a JSON array in sorted order, the `.seen` shape: opened
O_NOFOLLOW | O_NONBLOCK with an S_ISREG check and a 1 MiB cap, so a planted
symlink is refused and a FIFO can no longer hang every Desk render (the old
read_text() blocked on one). Writes go through mkstemp + os.replace. The
legacy line format is still READ, so the 6 live line-format files keep their
blur until their next write upgrades them. Measured before the change: 42
live rels, none with edge whitespace, so the defect had no live victims.
- The route no longer strips `f`.
- scripts/booth `blur`/`unblur` go through booth.blur.set_blurred instead of
their own grep/printf line writer. Two writers of one format is how the
formats drift, and after this change the shell writer would have appended a
line to a JSON array. Every path is checked before anything is written.
- Item.blurred_self (appended to the record): the item's own blur, resolved in
booth_items from the same read as `blurred`. It replaces build_gallery's
second read_blurred, which a write between the two reads could split
(invariant 3). app.py no longer reads blur state at all, and a test asserts
it.
Names stay importable from booth.app and booth.items (invariant 4). blur joins
test_stdlib_only. test_cli's per-item-survives test now reads through the reader
rather than asserting the old byte format. The r2b contract and its mutation
row follow blurred_self onto the record. tests/mutations/blur_storage.toml
proves 12 falsifiers by running the change each forbids.
Not in this change, and still ours: the "off"-means-ON idiom drift between
/blur, /blurbooth and /flag (forms only ever send 0/1), and the CLI's
`.blurbooth` touch following a symlink where the service no longer does.
This commit is contained in:
+10
-25
@@ -102,6 +102,9 @@ from booth.items import ( # noqa: E402,F401
|
||||
render_doc,
|
||||
render_doc_body,
|
||||
)
|
||||
# The per-item blur writer lives with its reader in booth/blur.py, stdlib-only so
|
||||
# `scripts/booth blur` shares both. Re-exported: tests import it from here.
|
||||
from booth.blur import set_blurred # noqa: E402,F401
|
||||
|
||||
# Sentinel dotfile that exempts a booth from the TTL sweep. A dotfile because
|
||||
# the existing listing code already skips dotfiles, so it costs nothing in item
|
||||
@@ -163,24 +166,6 @@ def set_booth_blurred(booth: Path, on: bool) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
|
||||
"""Add or remove one item from the blur set. Atomic replace, so a crash
|
||||
mid-write cannot leave a half-file that read_blurred would parse as a
|
||||
shorter — and therefore more revealing — set. Returns the new set."""
|
||||
current = read_blurred(booth)
|
||||
if on:
|
||||
current.add(rel)
|
||||
else:
|
||||
current.discard(rel)
|
||||
path = booth / BLUR_FILE
|
||||
if not current:
|
||||
path.unlink(missing_ok=True)
|
||||
return current
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text("".join(f"{r}\n" for r in sorted(current)))
|
||||
tmp.replace(path)
|
||||
return current
|
||||
|
||||
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
|
||||
# can use it without pulling FastAPI in. Re-exported here because call sites and
|
||||
# tests already reference these names through app.
|
||||
@@ -818,10 +803,6 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
suite reaches for it by name in nine places.
|
||||
"""
|
||||
out = []
|
||||
# r2b D2b: the item's OWN blur, apart from the booth's. `blurred` is the
|
||||
# composed fact the surfaces render; the per-item control changes only
|
||||
# this, and must not claim an un-blur the booth flag would override.
|
||||
own_blur = read_blurred(child)
|
||||
for it in booth_items(child):
|
||||
body = render_doc_body(child, it)
|
||||
rendered, rendered_html = body if body is not None else (None, False)
|
||||
@@ -844,7 +825,9 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
"rendered": rendered,
|
||||
"rendered_html": rendered_html,
|
||||
"blurred": it.blurred,
|
||||
"blurred_self": it.rel in own_blur,
|
||||
# r2b D2b: the item's OWN blur, apart from the booth's — off the
|
||||
# record, from the one read `blurred` came from (invariant 3).
|
||||
"blurred_self": it.blurred_self,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -2234,8 +2217,10 @@ def create_app(
|
||||
See BLUR_FILE: this hides an item from a glance, it does not protect it."""
|
||||
booth = resolve_booth(name)
|
||||
# Guard the path the same way the file route must: a blur entry is only
|
||||
# ever a booth-relative path, never an escape.
|
||||
rel = f.strip().lstrip("/")
|
||||
# ever a booth-relative path, never an escape. NEVER STRIPPED: " a.png"
|
||||
# and "a.png" are two items, and a stripped `f` blurred the neighbour
|
||||
# (heid bug-hunt on r2b merge 1). A leading "/" is never part of a rel.
|
||||
rel = f.lstrip("/")
|
||||
if ".." in Path(rel).parts:
|
||||
raise HTTPException(status_code=400, detail="bad item path")
|
||||
set_blurred(booth, rel, on not in ("0", "false", ""))
|
||||
|
||||
Reference in New Issue
Block a user