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.
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
"""Per-item blur storage — `.blurred`, one JSON array of booth-relative paths.
|
|
|
|
⚠ STDLIB ONLY (CLAUDE.md invariant 1). `scripts/booth blur` imports this under
|
|
the system python3 with no venv, so the service and the CLI share ONE reader and
|
|
ONE writer. The CLI used to keep its own grep/printf line writer, and two
|
|
writers of one file is how the formats would drift apart.
|
|
|
|
⚠ COSMETIC ONLY. A blurred item is still served, still in the zip, still on
|
|
disk. The Booth has no auth: if a thing must not be SEEN, it must not be in a
|
|
booth.
|
|
|
|
The `.seen` shape, for `.seen`'s reason: a rel may carry a leading space or a
|
|
newline, and the old one-stripped-rel-per-line format could not round-trip it.
|
|
Blurring " a.png" stored "a.png" and blurred the neighbour instead. The line
|
|
format is still READ, so a booth written before this change keeps its blur
|
|
until its next write upgrades the file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
BLUR_FILE = ".blurred"
|
|
|
|
# A blur set bigger than this is not one this service or the CLI wrote: a JSON
|
|
# array of every rel in a 270-item booth is a few KB. Same bound as `.seen`.
|
|
BLUR_MAX_BYTES = 1 << 20
|
|
|
|
|
|
def read_blurred(booth: Path) -> set[str]:
|
|
"""Blurred rels for a booth. Missing, unreadable or malformed -> empty set.
|
|
|
|
NEVER RAISES and NEVER BLOCKS. `booth_items` calls this for every booth the
|
|
Desk renders, and any fleet session can write into a booth, so the file may
|
|
be planted: it is opened without following a link and without blocking (a
|
|
FIFO with no writer), and refused unless it is a regular file of sane size.
|
|
|
|
Reads BOTH formats. A JSON array of strings is the current one; anything
|
|
that does not parse as a JSON array is the legacy one-rel-per-line format,
|
|
read exactly as before (stripped, blank lines dropped). Legacy rels starting
|
|
with "[" still read, because they fail the JSON parse and fall through.
|
|
Non-string members of an array are skipped, not fatal.
|
|
"""
|
|
try:
|
|
fd = os.open(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
except OSError:
|
|
return set()
|
|
try:
|
|
st = os.fstat(fd)
|
|
if not stat.S_ISREG(st.st_mode) or st.st_size > BLUR_MAX_BYTES:
|
|
return set()
|
|
raw = os.read(fd, BLUR_MAX_BYTES + 1)
|
|
except OSError:
|
|
return set()
|
|
finally:
|
|
os.close(fd)
|
|
try:
|
|
text = raw.decode("utf-8", "surrogateescape")
|
|
except UnicodeDecodeError: # pragma: no cover - surrogateescape cannot fail
|
|
return set()
|
|
try:
|
|
data = json.loads(text)
|
|
except (ValueError, RecursionError):
|
|
# RecursionError: a deeply nested array blows the parser's stack, and
|
|
# it is neither a ValueError nor an OSError (the `.seen` hole).
|
|
data = None
|
|
if isinstance(data, list):
|
|
return {r for r in data if isinstance(r, str)}
|
|
return {ln.strip() for ln in text.splitlines() if ln.strip()}
|
|
|
|
|
|
def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
|
|
"""Add or remove one rel from the blur set, and return the new set.
|
|
|
|
`rel` is stored EXACTLY as given; callers must not strip it. Written as a
|
|
JSON array in sorted order (CLAUDE.md invariant 6), so the same set is the
|
|
same bytes. An empty set removes the file: an empty marker is a lie by
|
|
omission.
|
|
|
|
Atomic replace (CLAUDE.md invariant 5) through a temp file created with
|
|
O_EXCL, so a crash mid-write cannot leave a shorter, and therefore more
|
|
revealing, set; a planted `.blurred.*.tmp` symlink cannot redirect the
|
|
write, and `os.replace` swaps a planted `.blurred` symlink out rather than
|
|
writing through it.
|
|
|
|
NOT locked. Two writers racing (the operator's click and a session's
|
|
`booth blur`) can lose one toggle; both are rare, deliberate and visible on
|
|
the next render, so this matches what the line format did.
|
|
"""
|
|
current = read_blurred(booth)
|
|
if on:
|
|
current.add(rel)
|
|
else:
|
|
current.discard(rel)
|
|
path = booth / BLUR_FILE
|
|
if not current:
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
return current
|
|
body = json.dumps(sorted(current), ensure_ascii=False).encode("utf-8", "surrogateescape")
|
|
fd, tmp = tempfile.mkstemp(prefix=".blurred.", suffix=".tmp", dir=booth)
|
|
try:
|
|
with os.fdopen(fd, "wb") as fh:
|
|
fh.write(body)
|
|
os.replace(tmp, path)
|
|
except BaseException:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
return current
|