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:
vh
2026-09-23 22:05:18 -07:00
parent cce6a20abe
commit 4cfbce5109
12 changed files with 580 additions and 89 deletions
+20 -12
View File
@@ -144,7 +144,6 @@ set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
@@ -306,7 +305,6 @@ case "$cmd" in
[ $# -ge 1 ] || usage
b="$1"; shift
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
f="$DATA/$b/$BLUR"
# NO FILES NAMED = THE WHOLE BOOTH. The Desk shows up to four images from
# every booth on the page the operator opens first, so a booth that should
@@ -327,23 +325,33 @@ case "$cmd" in
exit 0
fi
# Every item is checked BEFORE anything is written, so a refused path
# leaves the blur set exactly as it was.
items=()
for item in "$@"; do
item="${item#"$DATA/$b/"}"; item="${item#/}"
case "$item" in
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
esac
[ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
touch "$f"
if [ "$cmd" = blur ]; then
grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
else
grep -vxF -- "$item" "$f" > "$f.tmp" || true
mv -- "$f.tmp" "$f"
fi
items+=("$item")
done
# An empty marker is a lie by omission — `ls -a` should say whether
# anything here is blurred at all.
[ -s "$f" ] || rm -f -- "$f"
# ONE WRITER. `.blurred` is a JSON array now (a rel may carry a leading
# space or a newline, and the old line format could not round-trip it), and
# the service writes it too — so the CLI goes through the same stdlib-only
# booth.blur the service does, never a grep/printf of its own. Items travel
# as argv, which carries any byte but NUL; an env var or a line would not.
# An emptied set removes the file (booth.blur), so `ls -a` still says
# whether anything here is blurred at all.
BOOTH_SRC="$(booth_src)" BOOTH_DIR="$DATA/$b" python3 -c '
import os, sys
from pathlib import Path
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.blur import set_blurred # stdlib only — no venv needed
on = sys.argv[1] == "blur"
for rel in sys.argv[2:]:
set_blurred(Path(os.environ["BOOTH_DIR"]), rel, on)
' "$cmd" "${items[@]}"
if [ "$cmd" = blur ]; then
echo "blurred (cosmetic — still served): $URL/b/$b/"
else