merge(blur): the blur set round-trips any rel, in .blurred.json, with one writer

Operator-ruled 2026-09-23 ("fix the blur"). 4cfbce5 is the fix: a JSON-array
blur set through stdlib-only booth/blur.py, shared by the service and `booth
blur`, plus Item.blurred_self so blur state has one reader. c1f5543 folds the
heid bug-hunt on it (hulda, regin, kimi). The format moves to its own name,
.blurred.json, because sniffing one file for two formats recreated the
wrong-item bug. The writer is judged by its reader, so a planted directory is
a 409 and not a 500. A lone surrogate is dropped, the writer respects the
reader's size cap, and the route and the CLI share one check_rel predicate.
853 passed on the branch; blur_storage.toml 20/20.
This commit is contained in:
vh
2026-09-23 23:07:07 -07:00
12 changed files with 949 additions and 98 deletions
+43 -21
View File
@@ -39,7 +39,7 @@ lags the code defeats its own purpose.
These are the ones a casual change breaks silently. Each has a test. These are the ones a casual change breaks silently. Each has a test.
### 1. `links.py`, `asks.py` and `marks.py` are stdlib-only, on purpose ### 1. The modules `scripts/booth` imports are stdlib-only, on purpose
`scripts/booth` — the CLI every fleet session uses — imports them directly: `scripts/booth` — the CLI every fleet session uses — imports them directly:
@@ -48,39 +48,61 @@ BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.marks
``` ```
It runs under the system `python3` with **no venv**. A single third-party It runs under the system `python3` with **no venv**. A single third-party
import in any of the three breaks `booth ask` / `booth marks` / `booth answer` / import in any of them breaks `booth ask` / `booth marks` / `booth answer` /
`booth unlink` on every host, and the failure surfaces in an agent's session, `booth unlink` / `booth blur` on every host, and the failure surfaces in an
not in ours. agent's session, not in ours.
`items.py` and `app.py` are free to import what they like. Those three are not. The set is `marks`, `asks`, `links`, `manifest`, `benches`, `blur` and
`test_stdlib_only` walks each module's AST imports and asserts it — the CLI `__init__` (which runs before every one of them). **The list of record is
imports through a `python3 -c` heredoc that no AST extractor can see, so that `test_stdlib_only`'s parametrize in `tests/test_marks.py`**, not this
test is the only thing standing here. paragraph. `items.py` and `app.py` are free to import what they like; those are
not. `test_stdlib_only` walks each module's AST imports and asserts it — the
CLI imports through a `python3 -c` heredoc that no AST extractor can see, so
that test is the only thing standing here. A new module the CLI imports goes on
that list in the same commit.
### 2. The filesystem is the state ### 2. The filesystem is the state
No database. `ls ~/booth-data` tells you everything the service knows. No database. `ls ~/booth-data` tells you everything the service knows.
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep), Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one `.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred.json`
rel per line — ⚠ see below), `.seen` (R2: rels looked at full size, a JSON (the per-item blur set, a JSON ARRAY — see below; the legacy `.blurred` is
ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not read-only), `.seen` (R2: rels looked at full size, a JSON ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not
JSON, because a boolean has no rels to round-trip), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin JSON, because a boolean has no rels to round-trip), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin
ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
dotfile costs nothing in item counts, galleries or zips. That skip is why the dotfile costs nothing in item counts, galleries or zips. That skip is why the
dotfile is the right shape for new operator state — use it rather than dotfile is the right shape for new operator state — use it rather than
inventing a sidecar-per-item. inventing a sidecar-per-item.
⚠ **`.seen` is a JSON array where `.blurred` is one stripped rel per line, and ⚠ **A dotfile that holds rels is a JSON array, opened `O_NOFOLLOW |
the difference is a latent bug in the older one.** A rel may carry a leading O_NONBLOCK` with an `S_ISREG` check and a size cap.** A rel may carry a leading
space or a newline; line-stripped storage does not round-trip it, so blurring space or a newline, and line-stripped storage does not round-trip it: `.blurred`
`" a.png"` can blur `a.png` instead. `.seen` was written as JSON for exactly was one stripped rel per line, and blurring `" a.png"` blurred `a.png` instead.
that reason (design-dev, R2), and it also opens `O_NOFOLLOW | O_NONBLOCK` with `.seen` was written as JSON for exactly that reason (design-dev, R2), and the
an `S_ISREG` check — a planted symlink is refused and a FIFO cannot hang the blur set now matches it in `.blurred.json` (`booth/blur.py`). The open flags
read, which is the outage in mean a planted symlink is refused and a FIFO cannot hang the read, which is the
`persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new dotfile outage in `persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new
inherits that shape, not `.blurred`'s.** `.blurred` itself is unfixed and dotfile inherits that shape.**
pre-existing.
⚠ **A format change gets a NEW NAME, never a sniffed file.** The first cut of
the blur fix wrote JSON into `.blurred` and guessed the format from the bytes;
a legacy file whose one line is an item literally named `["a.png"]` parses as
JSON and blurs the neighbour, the bug being fixed (heid bug-hunt, 3 of 3). So
`.blurred.json` is JSON only, the legacy `.blurred` is lines only and read only
while `.blurred.json` is absent, and the first write retires it. Do not remove
that legacy read while a line-format file can still exist.
**A writer is judged by its reader.** `set_blurred` re-reads after writing and
raises `BlurUnwritable` unless the reader returns exactly the set asked for.
One postcondition covers a planted directory, a permission and a race without
a branch per way the disk can be wrong; the route answers it 409, never 500.
**A dotfile with two writers has ONE implementation of the writer, and one
predicate for its keys.** The blur set is written by the service and by `booth
blur`; both call `booth.blur.set_blurred`, and both ask `check_rel` what an
item path is. The CLI used to keep a grep/printf writer and a `*..*` guard of
its own, which refused `a..b.png` where the route accepted it.
### 3. One resolver for item facts ### 3. One resolver for item facts
+20 -29
View File
@@ -102,6 +102,9 @@ from booth.items import ( # noqa: E402,F401
render_doc, render_doc,
render_doc_body, 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 BlurUnwritable, check_rel, set_blurred # noqa: E402,F401
# Sentinel dotfile that exempts a booth from the TTL sweep. A dotfile because # 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 # 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 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 # 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 # can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app. # 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. suite reaches for it by name in nine places.
""" """
out = [] 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): for it in booth_items(child):
body = render_doc_body(child, it) body = render_doc_body(child, it)
rendered, rendered_html = body if body is not None else (None, False) 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": rendered,
"rendered_html": rendered_html, "rendered_html": rendered_html,
"blurred": it.blurred, "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 return out
@@ -2238,12 +2221,20 @@ def create_app(
"""Toggle one item's blur. Reversible and cosmetic, so no confirmation. """Toggle one item's blur. Reversible and cosmetic, so no confirmation.
See BLUR_FILE: this hides an item from a glance, it does not protect it.""" See BLUR_FILE: this hides an item from a glance, it does not protect it."""
booth = resolve_booth(name) booth = resolve_booth(name)
# Guard the path the same way the file route must: a blur entry is only # NEVER STRIPPED: " a.png" and "a.png" are two items, and a stripped
# ever a booth-relative path, never an escape. # `f` blurred the neighbour (heid bug-hunt on r2b merge 1). A leading
rel = f.strip().lstrip("/") # "/" is never part of a rel. What else a blur entry may be is
if ".." in Path(rel).parts: # `check_rel`'s one predicate, shared with `booth blur`, so the route
raise HTTPException(status_code=400, detail="bad item path") # and the CLI cannot disagree about which items are addressable.
set_blurred(booth, rel, on not in ("0", "false", "")) rel = f.lstrip("/")
try:
set_blurred(booth, rel, on not in ("0", "false", ""))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except BlurUnwritable as exc:
# The state on disk is wrong (a planted directory, a permission),
# not the request: a refusal, never a 500.
raise HTTPException(status_code=409, detail=str(exc))
return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303)
@app.post("/b/{name}/delete") @app.post("/b/{name}/delete")
+195
View File
@@ -0,0 +1,195 @@
"""Per-item blur storage — `.blurred.json`, 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,
ONE writer and ONE predicate for what an item path is. The CLI used to keep its
own grep/printf line writer, and two writers of one file is how formats drift.
⚠ 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.
WHY A NEW FILE NAME, NOT A NEW FORMAT IN THE OLD FILE. `.blurred` was one
stripped rel per line, which could not round-trip a rel with a leading space or
a newline (blurring " a.png" blurred "a.png"). A JSON array fixes that, the
`.seen` shape. Writing it into the OLD name would force the reader to sniff
which format it is looking at, and sniffing cannot be made safe: a legacy file
whose one line is an item literally named `["a.png"]` parses as a JSON array and
would blur the neighbour, the very bug this module exists to fix (heid bug-hunt,
3 of 3 arms). So the two formats live at two names and neither is ever guessed:
.blurred.json current. JSON only, never read as lines.
.blurred legacy, READ ONLY, and only while `.blurred.json` is absent.
Lines only, never read as JSON. The first write replaces it.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
from pathlib import Path
BLUR_FILE = ".blurred.json"
LEGACY_BLUR_FILE = ".blurred"
# A blur set bigger than this is not one this module wrote: a JSON array of
# every rel in a 270-item booth is a few KB. Same bound as `.seen`, and the
# WRITER enforces it too, so the writer can never produce a file the reader
# would refuse and read as nothing.
BLUR_MAX_BYTES = 1 << 20
class BlurUnwritable(Exception):
"""The blur set on disk could not be made to hold what was asked: something
that is not ours is in the way (a directory at the name, a permission), or
the set would outgrow what the reader accepts. A refusal about the STATE ON
DISK, not about the request, so the route answers 409, never 500."""
def check_rel(rel: str) -> str:
"""The one predicate for what a blur entry may be, shared by the route and
the CLI so the two cannot disagree about which items are addressable.
A booth-relative path: not empty, not absolute, no `..` COMPONENT (so
`a..b.png` is a fine name, and `a/../b` is not), and encodable back to the
bytes of a filename. Stored EXACTLY as given otherwise — never stripped.
Raises ValueError; returns `rel` unchanged."""
if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError(f"not a booth-relative item path: {rel!r}")
if not _encodable(rel):
raise ValueError(f"not a filename this box can hold: {rel!r}")
return rel
def _encodable(rel: str) -> bool:
"""A real filename decodes under surrogateescape to U+DC80..U+DCFF at worst,
which encodes back. A lone U+D800 cannot come from any filename, only from a
planted JSON escape, and would make every later write raise."""
try:
rel.encode("utf-8", "surrogateescape")
except UnicodeEncodeError:
return False
return True
def _read_capped(path: Path) -> bytes | None:
"""A regular file's bytes, or None. Never follows a link, never blocks on a
FIFO, never reads past the cap, never raises."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return None
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > BLUR_MAX_BYTES:
return None
return os.read(fd, BLUR_MAX_BYTES + 1)
except OSError:
return None
finally:
os.close(fd)
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 either file
may be planted: each is opened without following a link and without
blocking, and refused unless it is a regular file of sane size.
ANYTHING at `.blurred.json` (a link or a directory included) means the
current format is in charge, and the legacy file is not consulted, so a
stale `.blurred` left beside a newer set can never speak. Members that are
not strings, are empty, or could not be a filename are skipped.
"""
try:
os.lstat(booth / BLUR_FILE)
except FileNotFoundError:
raw = _read_capped(booth / LEGACY_BLUR_FILE)
if raw is None:
return set()
text = raw.decode("utf-8", "surrogateescape")
return {ln.strip() for ln in text.splitlines() if ln.strip()}
except OSError:
return set()
raw = _read_capped(booth / BLUR_FILE)
if raw is None:
return set()
try:
data = json.loads(raw.decode("utf-8", "surrogateescape"))
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).
return set()
if not isinstance(data, list):
return set()
return {r for r in data if isinstance(r, str) and r and _encodable(r)}
def _discard(path: Path) -> None:
try:
path.unlink()
except OSError:
pass # judged by the postcondition in set_blurred, not here
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` must pass `check_rel` (ValueError otherwise) and is stored EXACTLY as
given. 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, because an
empty marker is a lie by omission. The first write also retires a legacy
`.blurred`, AFTER the new file is in place, so a crash between the two
leaves the new file in charge.
Atomic replace (CLAUDE.md invariant 5) through a temp file created with
O_EXCL: a crash mid-write cannot leave a shorter, more revealing set, and
`os.replace` swaps a planted symlink out rather than writing through it.
SUCCESS IS DEFINED BY THE READER. After writing, `read_blurred` must return
exactly the set asked for; anything else raises BlurUnwritable. That one
check covers a planted directory at either name, a permission, and a race,
without a branch per way the disk can be wrong.
NOT locked. Two writers racing (the operator's click and a session's
`booth blur`) can lose one toggle, as the line format could.
"""
check_rel(rel)
current = read_blurred(booth)
if on:
current.add(rel)
else:
current.discard(rel)
path = booth / BLUR_FILE
legacy = booth / LEGACY_BLUR_FILE
if current:
body = json.dumps(sorted(current), ensure_ascii=False).encode("utf-8", "surrogateescape")
if len(body) > BLUR_MAX_BYTES:
raise BlurUnwritable(
f"{len(current)} blurred items would exceed the {BLUR_MAX_BYTES}-byte "
f"bound the reader accepts; nothing was changed")
try:
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:
_discard(Path(tmp))
raise
except OSError:
pass # judged by the postcondition below
else:
_discard(legacy)
else:
_discard(path)
_discard(legacy)
if read_blurred(booth) != current:
raise BlurUnwritable(
f"the blur set in {booth.name!r} could not be written; is something other "
f"than a file at {BLUR_FILE} or {LEGACY_BLUR_FILE}?")
return current
+10 -10
View File
@@ -30,6 +30,7 @@ except ImportError: # pragma: no cover
_markdown = None _markdown = None
from booth.asks import is_answer_file, is_ask_file from booth.asks import is_answer_file, is_ask_file
from booth.blur import BLUR_FILE, read_blurred # noqa: F401 (re-exported)
from booth.thumbs import wants_thumb from booth.thumbs import wants_thumb
# Browser-playable media buckets. Anything else renders as a download link. # Browser-playable media buckets. Anything else renders as a download link.
@@ -44,7 +45,8 @@ TEXT_EXTS = {".txt", ".text", ".log"}
CAPTION_MAX = 800 # chars of a sidecar .txt caption we render CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
BLUR_FILE = ".blurred" # `BLUR_FILE` and `read_blurred` live in booth/blur.py (stdlib-only, so the CLI
# shares the reader and the writer) and are re-exported from here.
# Booth-level blur: the whole booth is fogged, agent-set at post time or # Booth-level blur: the whole booth is fogged, agent-set at post time or
# toggled by the operator. A MARKER, deliberately not JSON like `.seen` — # toggled by the operator. A MARKER, deliberately not JSON like `.seen` —
@@ -119,6 +121,12 @@ class Item:
# Derived HERE so no template reasons about `kind` to decide — INV-1, which # Derived HERE so no template reasons about `kind` to decide — INV-1, which
# is the caption bug in a new field. # is the caption bug in a new field.
thumb: str | None thumb: str | None
# The item's OWN per-item blur, apart from the booth's fog: the per-item
# control toggles only this, so it must not offer an un-blur the booth flag
# would override (r2b D2b). From the SAME read as `blurred` — it used to be
# a second `read_blurred` in build_gallery, and a write between the two
# reads could split them (invariant 3). APPENDED, like `ordinal`.
blurred_self: bool
# R2 C2: which items have been looked at full size. UI state, not judgment — # R2 C2: which items have been looked at full size. UI state, not judgment —
@@ -167,15 +175,6 @@ def read_seen(booth: Path) -> set[str]:
return {r for r in data if isinstance(r, str)} return {r for r in data if isinstance(r, str)}
def read_blurred(booth: Path) -> set[str]:
"""Blurred item paths for a booth. Missing file -> empty set."""
try:
text = (booth / BLUR_FILE).read_text()
except (OSError, UnicodeDecodeError):
return set()
return {ln.strip() for ln in text.splitlines() if ln.strip()}
def is_booth_blurred(booth: Path) -> bool: def is_booth_blurred(booth: Path) -> bool:
"""Whether the WHOLE booth is blurred. """Whether the WHOLE booth is blurred.
@@ -399,6 +398,7 @@ def booth_items(booth: Path) -> list[Item]:
# stay contiguous over what the operator can see. # stay contiguous over what the operator can see.
ordinal=len(items) + 1, ordinal=len(items) + 1,
thumb=(quote(rel, safe='/') + '?thumb=1') if wants_thumb(rel) else None, thumb=(quote(rel, safe='/') + '?thumb=1') if wants_thumb(rel) else None,
blurred_self=rel in blurred,
) )
) )
return items return items
@@ -17,7 +17,7 @@ estimated_loc: 350
confidence: 0.7 confidence: 0.7
touches: touches:
- "booth/templates/index.html (the row: facts line, lifetime pill, the hover cluster, the `blurred` badge; the confirm script unchanged)" - "booth/templates/index.html (the row: facts line, lifetime pill, the hover cluster, the `blurred` badge; the confirm script unchanged)"
- "booth/app.py (READS only, no new route: `booth_blurred` in the booth_view and booth_view_file contexts and on each list_booths row; `blurred_self` on each gallery dict from build_gallery's one `read_blurred`)" - "booth/app.py (READS only, no new route: `booth_blurred` in the booth_view and booth_view_file contexts and on each list_booths row; `blurred_self` on each gallery dict, read off `Item.blurred_self` — moved onto the item record by booth-dev 2026-09-23 so blur state has one reader, invariant 3)"
- "booth/templates/base.html (Desk row CSS; reveal-all CSS; the theme toggle markup in the top bar; the early <head> script; the toggle script)" - "booth/templates/base.html (Desk row CSS; reveal-all CSS; the theme toggle markup in the top bar; the early <head> script; the toggle script)"
- "booth/templates/booth.html (Reveal all in the booth header; per-tile reveal defers to it)" - "booth/templates/booth.html (Reveal all in the booth header; per-tile reveal defers to it)"
- "booth/templates/view.html (Reveal all in the review; the stage reveal defers to it)" - "booth/templates/view.html (Reveal all in the review; the stage reveal defers to it)"
@@ -214,7 +214,7 @@ reading; this is the control the operator uses, which the blur ruling assumed.
- **Each item's own blur control tells the truth under a fogged booth.** - **Each item's own blur control tells the truth under a fogged booth.**
`Item.blurred` is the COMPOSED fact (own OR booth). The per-item form changes `Item.blurred` is the COMPOSED fact (own OR booth). The per-item form changes
only the item's own entry in `.blurred`, so the gallery also carries only the item's own entry in `.blurred`, so the gallery also carries
`blurred_self`, read from the same single `read_blurred`. `blurred_self`, from `Item.blurred_self` (the same single read `booth_items` makes for `blurred`).
- An item blurred only because the booth is shows "◉ booth", a label with no - An item blurred only because the booth is shows "◉ booth", a label with no
form, pointing at the header. A per-item un-blur there would be overridden form, pointing at the header. A per-item un-blur there would be overridden
by the booth flag and visibly do nothing. by the booth flag and visibly do nothing.
@@ -283,7 +283,7 @@ reading; this is the control the operator uses, which the blur ruling assumed.
- **INV-1 — nothing new on the server beyond READS.** No route and no file are - **INV-1 — nothing new on the server beyond READS.** No route and no file are
added: `booth_blurred` in two contexts and on the Desk row (`is_booth_blurred`), added: `booth_blurred` in two contexts and on the Desk row (`is_booth_blurred`),
and `blurred_self` per gallery item (`read_blurred`, once per page). D2 and D3 are per-browser state; D1 is markup and CSS. and `blurred_self` per gallery item (`Item.blurred_self`, from `booth_items`' one read). D2 and D3 are per-browser state; D1 is markup and CSS.
- **INV-2 — JS-off parity (r2 INV-3).** Every control on the row works with - **INV-2 — JS-off parity (r2 INV-3).** Every control on the row works with
scripts off. Reveal all and the toggle do not render without JS. The page scripts off. Reveal all and the toggle do not render without JS. The page
follows the OS. follows the OS.
+39 -16
View File
@@ -19,22 +19,45 @@ loop it turned out to actually be.
_As of 2026-09-23:_ _As of 2026-09-23:_
- 🛑 **THE ONE THING BLOCKING: design-dev's blur merge is HELD, awaiting his - ✅ **BOTH r2b MERGES LANDED AND ARE LIVE** (operator-approved 2026-09-23):
ping.** `design-dev/svos-retheme` @ `5ded5ff` (Reveal all + the booth-blur `b92b002` (Reveal all + the booth-blur control, design-dev `ca0641f`) and
control) is fetched, merge-tree against `091f4b5` is CLEAN, and his commit `cce6a20` (the Desk row, booth dates, the theme toggle, `1558a7f`). Each got a
records the contract panel 4/4 folded, 14/14 mutations, 765 passing — full suite, a restart and a sweep: 25 live booths, 19 review pages and every
**but the heid code-review and bug-hunt panels were dispatched 17:53 and are marks page at 200. ⚠ **A peer's "merge it" is not the operator's approval
NOT folded.** He said explicitly: *"Hold… I will ping 'merge it' when both here.** The permission layer refused the merge on design-dev's word alone, and
are folded; whoever picks it up after a clear should look for that ping before that was right: put the merge to the operator.
merging."* **DO NOT MERGE IT WITHOUT THAT PING.** The operator's "merge - 🔶 **NEXT, design-dev's: r2c, the review stage.** Fit/1:1 always shown; **Fit
everything" predates this and was not about overriding his gate. may enlarge** (operator, 2026-09-23); the arrows hug the image; drag-pan in
- 🔶 **After that ping: merge, restart, verify 30 booths.** Then his SECOND 1:1 with native image drag killed; the mode is remembered per viewer. Pan
merge (Desk row revisions + the dark/light/system theme toggle) follows the offset across items is parked to r3 (compare). Then **r3, compare mode**:
same way. ours is only the `booth_items` support he asks for.
- ⚠ **THE BROWSER SUITE IS FLAKY UNDER LOAD AND IT IS NOT FIXED.** Three - ✅ **THE BLUR SET ROUND-TRIPS ANY REL** (operator: "fix the blur"). It lives
different tests, one failure each, all passing in isolation. Two real defects in `.blurred.json`, a JSON array written through stdlib-only `booth/blur.py`,
were fixed chasing it and NEITHER is proven to be the cause. **Do not read a which is the one writer and one `check_rel` predicate for both the service
green suite as proof.** Operator ruled design-dev diagnoses it properly. and `booth blur`. The legacy `.blurred` is read as lines, only while no
`.blurred.json` exists, and the first write retires it. The original bug (a
stripped rel blurred its neighbour) had no live victims: 6 legacy files, 42
rels, none with edge whitespace, none parseable as JSON. The heid bug-hunt
(3 arms, groa timed out) folded: a planted directory now gets a 409 instead
of a 500, the legacy file is never sniffed for JSON, a lone-surrogate member
is dropped, the writer respects the reader's size cap, the CLI takes
`a..b.png` and refuses an empty path, and a missing package fails closed.
Declined: the `Item` positional-constructor break (booth_items is the only
constructor, INV-1), the fdopen fd leak, the short read, and
unreadable-reads-as-revealed (the `.seen` posture). `Item.blurred_self` came
along, so blur state has one reader (invariant 3). **Still ours, not done:**
"off" means ON for /blur and /blurbooth but OFF for /flag (forms only send
0/1), and the CLI's `.blurbooth` `touch` still follows a symlink where the
service no longer does.
- ⚠ **THE BROWSER SUITE WAS FLAKY UNDER LOAD, AND THE CAUSE IS STILL
UNCONFIRMED.** design-dev's suspect: Google Fonts stalling "networkidle". He
reproduced the exact error with a stalled font request (sufficiency only).
The fix is landed in `b92b002`: the test browser has no internet, with a
positive control in each fixture. Since then, **0 reds in 24** untraced runs
against a pre-fix rate of about 1 in 8. That rate is itself 1 red in 8 runs
(95% CI roughly 0.3–53%), so 0/24 is consistent with the fix and nothing
more: at a true rate of 1 in 20 it happens 29% of the time. No trace ever
caught the stalled request. **Do not read a green suite as proof.**
→ `persistent-memory.d/2026-09-23-the-browser-suite-is-flaky-under-load.md` → `persistent-memory.d/2026-09-23-the-browser-suite-is-flaky-under-load.md`
- ✅ **THE REDESIGN IS LIVE.** R2 (the Desk, the lightbox, the reel) merged and - ✅ **THE REDESIGN IS LIVE.** R2 (the Desk, the lightbox, the reel) merged and
deployed; release/wipe moved onto the facts line. 30 booths at 200. deployed; release/wipe moved onto the facts line. 30 booths at 200.
+41 -16
View File
@@ -144,7 +144,6 @@ set -euo pipefail
DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}" DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}" URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever" # must match KEEP_MARKER in booth/app.py 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}" LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they # `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
@@ -306,7 +305,6 @@ case "$cmd" in
[ $# -ge 1 ] || usage [ $# -ge 1 ] || usage
b="$1"; shift b="$1"; shift
[ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; } [ -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 # 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 # every booth on the page the operator opens first, so a booth that should
@@ -327,23 +325,50 @@ case "$cmd" in
exit 0 exit 0
fi fi
# Items are made booth-relative here; WHETHER each one is an item path is
# booth.blur.check_rel's call, the same predicate the web route uses, so
# `booth blur g a..b.png` and the operator's click agree. (A `*..*`
# substring test here refused `a..b.png`, which the route accepted.)
items=()
for item in "$@"; do for item in "$@"; do
item="${item#"$DATA/$b/"}"; item="${item#/}" item="${item#"$DATA/$b/"}"
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 [ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
touch "$f" items+=("$item")
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
done done
# An empty marker is a lie by omission — `ls -a` should say whether # ONE WRITER. `.blurred.json` is a JSON array (a rel may carry a leading
# anything here is blurred at all. # space or a newline, which the old `.blurred` line format could not
[ -s "$f" ] || rm -f -- "$f" # round-trip), and the service writes it too, so the CLI goes through the
# same stdlib-only booth.blur, never a grep/printf of its own. Items travel
# as argv, which carries any byte but NUL. EVERY item is checked before ANY
# is written, so a refused path leaves the blur set exactly as it was.
# Exit 2: an item path refused. Exit 3: nothing written, and why (the
# package is missing, or something that is not a file is in the way).
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"])
try:
from booth.blur import BlurUnwritable, check_rel, set_blurred # stdlib only
except ImportError as exc:
src = os.environ["BOOTH_SRC"]
sys.stderr.write(f"booth blur: cannot load booth.blur from {src} ({exc}).\n"
" Run the booth script from its checkout, beside its booth/ package. Nothing was changed.\n")
sys.exit(3)
on = sys.argv[1] == "blur"
rels = [r.lstrip("/") for r in sys.argv[2:]]
for rel in rels:
try:
check_rel(rel)
except ValueError as exc:
sys.stderr.write(f"booth blur: refusing {rel!r}: {exc}. Nothing was changed.\n")
sys.exit(2)
for rel in rels:
try:
set_blurred(Path(os.environ["BOOTH_DIR"]), rel, on)
except BlurUnwritable as exc:
sys.stderr.write(f"booth blur: {exc}\n")
sys.exit(3)
' "$cmd" "${items[@]}"
if [ "$cmd" = blur ]; then if [ "$cmd" = blur ]; then
echo "blurred (cosmetic — still served): $URL/b/$b/" echo "blurred (cosmetic — still served): $URL/b/$b/"
else else
+212
View File
@@ -0,0 +1,212 @@
# Per-item blur storage: `.blurred` round-trips any rel, whoever writes it.
# The fix for the wrong-item write the heid bug-hunt found through r2b merge 1
# (a stripped rel blurred its neighbour), operator-ruled 2026-09-23. Every row
# is a change tests/test_blur.py claims to forbid.
#
# NOT here, on purpose: the S_ISREG guard in read_blurred. With O_NONBLOCK a
# FIFO opens and reads as EOF, a symlink is already refused by O_NOFOLLOW, and a
# device node needs root to plant, so no test here can see that guard go. It
# stays as the `.seen` shape, and it is not claimed as a proven falsifier.
unit = "blur storage round-trip"
[[mutation]]
label = "the writer strips the rel (the old line format's loss)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_leading_space_rel_round_trips"
old = '''
current.add(rel)'''
new = '''
current.add(rel.strip())'''
[[mutation]]
label = "the route strips `f` before writing (the reported wrong-item write)"
file = "booth/app.py"
test = "tests/test_blur.py::test_the_blur_route_blurs_exactly_the_item_it_names"
old = '''
rel = f.lstrip("/")'''
new = '''
rel = f.strip().lstrip("/")'''
[[mutation]]
label = "a JSON-only reader: every live line-format file un-blurs on deploy"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_legacy_line_format_still_reads"
old = '''
return {ln.strip() for ln in text.splitlines() if ln.strip()}'''
new = '''
return set()'''
[[mutation]]
label = "a legacy file that is not JSON reads as nothing instead of falling back"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_legacy_rel_that_starts_with_a_bracket_still_reads"
old = '''
text = raw.decode("utf-8", "surrogateescape")
return {ln.strip()'''
new = '''
text = raw.decode("utf-8", "surrogateescape")
try:
json.loads(text)
except ValueError:
return set()
return {ln.strip()'''
[[mutation]]
label = "a FIFO blocks the read (no O_NONBLOCK)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_fifo_blur_file_does_not_block_the_read"
old = '''
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
new = '''
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)'''
[[mutation]]
label = "the read follows a planted symlink (no O_NOFOLLOW)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_symlinked_blur_file_is_not_followed_on_read"
old = '''
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
new = '''
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)'''
[[mutation]]
label = "the write goes through a planted symlink instead of replacing it"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it"
old = '''
os.replace(tmp, path)'''
new = '''
path.write_bytes(Path(tmp).read_bytes()); os.unlink(tmp)'''
[[mutation]]
label = "the stored order is not the stated one (invariant 6)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_file_is_a_json_array_in_sorted_order"
old = '''
body = json.dumps(sorted(current), ensure_ascii=False)'''
new = '''
body = json.dumps(sorted(current, reverse=True), ensure_ascii=False)'''
[[mutation]]
label = "the CLI ignores the verb: `unblur` blurs"
file = "scripts/booth"
test = "tests/test_blur.py::test_the_cli_writes_the_format_the_service_reads"
old = '''
on = sys.argv[1] == "blur"'''
new = '''
on = True'''
[[mutation]]
label = "the CLI writes past a refused '..' path (the shared predicate loses its component check)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_cli_still_refuses_a_dotdot_path"
old = '''
if not rel or rel.startswith("/") or ".." in rel.split("/"):'''
new = '''
if not rel or rel.startswith("/"):'''
[[mutation]]
label = "the item's own blur is the composed one (booth fog leaks into it)"
file = "booth/items.py"
test = "tests/test_blur.py::test_the_item_record_carries_its_own_blur_apart_from_the_booths"
old = '''
blurred_self=rel in blurred,'''
new = '''
blurred_self=rel in blurred or booth_blur,'''
[[mutation]]
label = "app.py reads the blur file a second time (invariant 3)"
file = "booth/app.py"
test = "tests/test_blur.py::test_app_py_never_reads_the_blur_file_itself"
old = '''
out = []
for it in booth_items(child):'''
new = '''
out = []
read_blurred(child)
for it in booth_items(child):'''
# ---- the heid bug-hunt on this change (hulda, regin, kimi), folded -------------
[[mutation]]
label = "the legacy file is sniffed for JSON again (a `[\"a.png\"]` line blurs the neighbour)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_legacy_line_that_is_valid_json_still_reads_as_a_line"
old = '''
text = raw.decode("utf-8", "surrogateescape")
return {ln.strip()'''
new = '''
text = raw.decode("utf-8", "surrogateescape")
try:
d = json.loads(text)
if isinstance(d, list):
return {r for r in d if isinstance(r, str)}
except ValueError:
pass
return {ln.strip()'''
[[mutation]]
label = "no postcondition: a planted directory's OSError is swallowed as success"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_planted_directory_at_the_blur_file_is_a_refusal_not_a_crash"
old = '''
if read_blurred(booth) != current:'''
new = '''
if False:'''
[[mutation]]
label = "the route turns a disk-state refusal into a 500"
file = "booth/app.py"
test = "tests/test_blur.py::test_the_route_answers_a_planted_directory_with_409"
old = '''
raise HTTPException(status_code=409, detail=str(exc))'''
new = '''
raise'''
[[mutation]]
label = "a lone surrogate from a planted file reaches the writer"
file = "booth/blur.py"
test = "tests/test_blur.py::test_a_lone_surrogate_in_the_file_is_skipped_and_writes_still_work"
old = '''
return {r for r in data if isinstance(r, str) and r and _encodable(r)}'''
new = '''
return {r for r in data if isinstance(r, str) and r}'''
[[mutation]]
label = "the writer writes a set the reader would refuse and read as nothing"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_writer_never_writes_a_set_the_reader_would_refuse"
old = '''
if len(body) > BLUR_MAX_BYTES:'''
new = '''
if False:'''
[[mutation]]
label = "an empty item path is accepted and stored"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_cli_refuses_an_empty_item_path_before_writing"
old = '''
if not rel or rel.startswith("/") or ".." in rel.split("/"):'''
new = '''
if rel.startswith("/") or ".." in rel.split("/"):'''
[[mutation]]
label = "a double dot INSIDE a name is refused (the old `*..*` substring rule)"
file = "booth/blur.py"
test = "tests/test_blur.py::test_the_cli_accepts_a_double_dot_inside_a_name"
old = '''
if not rel or rel.startswith("/") or ".." in rel.split("/"):'''
new = '''
if not rel or rel.startswith("/") or ".." in rel:'''
[[mutation]]
label = "the CLI dies with a traceback when its package is missing"
file = "scripts/booth"
test = "tests/test_blur.py::test_the_cli_fails_closed_without_its_package"
old = '''
except ImportError as exc:
src = os.environ["BOOTH_SRC"]'''
new = '''
except ZeroDivisionError as exc:
src = os.environ["BOOTH_SRC"]'''
+1 -1
View File
@@ -134,7 +134,7 @@ label = "D2b the per-item control reads the composed blur, not the item's own"
file = "booth/app.py" file = "booth/app.py"
test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth" test = "tests/test_flow.py::test_under_a_fogged_booth_each_items_blur_control_tells_the_truth"
old = ''' old = '''
"blurred_self": it.rel in own_blur,''' "blurred_self": it.blurred_self,'''
new = ''' new = '''
"blurred_self": it.blurred,''' "blurred_self": it.blurred,'''
+377
View File
@@ -0,0 +1,377 @@
"""Per-item blur storage — `.blurred` round-trips any rel, whoever writes it.
`.blurred` was one stripped rel per line, so a rel with a leading space could
not survive a write: blurring " a.png" stored "a.png", and toggled the
neighbour instead (heid bug-hunt on r2b merge 1, reported to booth-dev). The set
now lives in `.blurred.json`, a JSON array (the `.seen` shape), read without
following a link or blocking on a FIFO. The legacy `.blurred` is still READ, as
lines, while no `.blurred.json` exists; the first write retires it. Two names,
so neither format is ever sniffed (heid bug-hunt on this change, 3 of 3 arms).
Two writers share the file: the service (the operator's per-item control) and
`scripts/booth blur` (a session at post time). Both go through `booth.blur`,
which is stdlib-only so the CLI can import it under the system python3.
"""
from __future__ import annotations
import json
import os
import pathlib
import subprocess
import sys
import threading
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import BLUR_FILE, create_app, read_blurred, set_blurred # noqa: E402
from booth.blur import BLUR_MAX_BYTES, LEGACY_BLUR_FILE, BlurUnwritable # noqa: E402
from booth.items import booth_items # noqa: E402
PNG = b"\x89PNG\r\n\x1a\n"
SCRIPT = pathlib.Path(__file__).parent.parent / "scripts" / "booth"
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
b = root / name
b.mkdir()
for rel, data in files.items():
(b / rel).write_bytes(data)
return b
def _within(seconds: float, fn):
"""Run fn in a thread and fail, rather than hang the suite, if it blocks."""
out: dict = {}
t = threading.Thread(target=lambda: out.setdefault("v", fn()), daemon=True)
t.start()
t.join(seconds)
assert not t.is_alive(), f"{fn} blocked for over {seconds}s"
return out["v"]
# ---- the round-trip: the defect ---------------------------------------------
def test_a_leading_space_rel_round_trips(tmp_path):
"""Defeating change: storing rels line-stripped (the old format)."""
set_blurred(tmp_path, " a.png", True)
assert read_blurred(tmp_path) == {" a.png"}
def test_unblurring_a_leading_space_rel_leaves_its_neighbour_blurred(tmp_path):
"""The reported wrong-item write: " a.png" and "a.png" are two items, and
toggling one must never move the other. (Unblurring the SPACED one would
pass under the old format too — it was a no-op there — so this unblurs the
plain one and asks whether the spaced one survived.)"""
set_blurred(tmp_path, "a.png", True)
set_blurred(tmp_path, " a.png", True)
set_blurred(tmp_path, "a.png", False)
assert read_blurred(tmp_path) == {" a.png"}
def test_a_newline_in_a_rel_round_trips(tmp_path):
"""A line format cannot hold one at all."""
set_blurred(tmp_path, "two\nlines.png", True)
assert read_blurred(tmp_path) == {"two\nlines.png"}
def test_the_file_is_a_json_array_in_sorted_order(tmp_path):
"""The `.seen` shape, and a stated order (invariant 6) so two writes of the
same set are byte-identical."""
set_blurred(tmp_path, "b.png", True)
set_blurred(tmp_path, "a.png", True)
assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"]
def test_emptying_the_set_removes_the_file(tmp_path):
"""Unchanged: an empty marker is a lie by omission."""
set_blurred(tmp_path, "a.png", True)
set_blurred(tmp_path, "a.png", False)
assert not (tmp_path / BLUR_FILE).exists()
# ---- the legacy format: nothing live changes until it is written ------------
def test_the_legacy_line_format_still_reads(tmp_path):
"""Six live booths hold line-format files. Defeating change: a JSON-only
reader, which would un-blur every one of them on deploy."""
(tmp_path / LEGACY_BLUR_FILE).write_text("a.png\nsub/b.png\n\n")
assert read_blurred(tmp_path) == {"a.png", "sub/b.png"}
def test_a_legacy_rel_that_starts_with_a_bracket_still_reads(tmp_path):
"""A line-format file whose first rel happens to begin with "[" is not
JSON, and must fall back to lines rather than read as nothing."""
(tmp_path / LEGACY_BLUR_FILE).write_text("[draft] a.png\nb.png\n")
assert read_blurred(tmp_path) == {"[draft] a.png", "b.png"}
def test_a_write_upgrades_a_legacy_file_and_keeps_its_rels(tmp_path):
"""And retires the legacy file, so it can never speak again."""
(tmp_path / LEGACY_BLUR_FILE).write_text("a.png\n")
set_blurred(tmp_path, "b.png", True)
assert json.loads((tmp_path / BLUR_FILE).read_text("utf-8")) == ["a.png", "b.png"]
assert not (tmp_path / LEGACY_BLUR_FILE).exists()
# ---- a planted file: never blocks, never follows ----------------------------
def test_a_fifo_blur_file_does_not_block_the_read(tmp_path):
"""read_blurred runs for every booth the Desk renders; a FIFO with no writer
used to hang it — the outage class `.seen` was built against."""
os.mkfifo(tmp_path / BLUR_FILE)
assert _within(5, lambda: read_blurred(tmp_path)) == set()
def test_a_symlinked_blur_file_is_not_followed_on_read(tmp_path):
outside = tmp_path / "outside.json"
outside.write_text('["a.png"]')
b = tmp_path / "b"
b.mkdir()
(b / BLUR_FILE).symlink_to(outside)
assert read_blurred(b) == set()
def test_a_write_replaces_a_planted_symlink_rather_than_writing_through_it(tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("untouched")
b = tmp_path / "b"
b.mkdir()
(b / BLUR_FILE).symlink_to(outside)
set_blurred(b, "a.png", True)
assert outside.read_text() == "untouched"
assert not (b / BLUR_FILE).is_symlink()
assert read_blurred(b) == {"a.png"}
def test_malformed_json_array_contents_are_skipped_not_fatal(tmp_path):
(tmp_path / BLUR_FILE).write_text('["a.png", 3, null, ["x"]]')
assert read_blurred(tmp_path) == {"a.png"}
# ---- the route: the operator's per-item control -----------------------------
def test_the_blur_route_blurs_exactly_the_item_it_names(tmp_path):
"""The route stripped `f` before writing, so the form for " a.png" blurred
"a.png". Defeating change: `f.strip()` back in the route."""
b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG})
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": " a.png", "on": "1"}, follow_redirects=False)
assert r.status_code == 303
blurred = {it.rel: it.blurred for it in booth_items(b)}
assert blurred == {" a.png": True, "a.png": False}
# ---- the CLI: the other writer ----------------------------------------------
def _cli(data: pathlib.Path, *args: str) -> subprocess.CompletedProcess:
env = {**os.environ, "BOOTH_DATA_DIR": str(data), "BOOTH_URL": "http://booth.invalid"}
return subprocess.run([str(SCRIPT), *args], capture_output=True, text=True, env=env, timeout=30)
def test_the_cli_writes_the_format_the_service_reads(tmp_path):
"""Both writers, one format. Defeating change: the CLI keeping its own
grep/printf line writer, which appends a line to a JSON array."""
b = _booth(tmp_path, "g", {" a.png": PNG, "a.png": PNG})
set_blurred(b, "a.png", True) # the service wrote first
r = _cli(tmp_path, "blur", "g", " a.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a.png", " a.png"}
r = _cli(tmp_path, "unblur", "g", " a.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a.png"}
def test_the_cli_unblurring_the_last_item_removes_the_file(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
assert _cli(tmp_path, "blur", "g", "a.png").returncode == 0
assert _cli(tmp_path, "unblur", "g", "a.png").returncode == 0
assert not (b / BLUR_FILE).exists()
def test_the_cli_still_refuses_a_dotdot_path(tmp_path):
_booth(tmp_path, "g", {"a.png": PNG})
r = _cli(tmp_path, "blur", "g", "../escape.png")
assert r.returncode == 2
assert not (tmp_path / "g" / BLUR_FILE).exists()
# ---- one read of blur state per render (invariant 3) ------------------------
def test_the_item_record_carries_its_own_blur_apart_from_the_booths(tmp_path):
"""r2b's per-item control needs the item's OWN blur as well as the composed
one. It came from a second `read_blurred` in build_gallery — a second reader
of one file, which a write between the two could split. It is now resolved
in `booth_items`, from the one read the composed fact already uses."""
b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG})
set_blurred(b, "a.png", True)
(b / ".blurbooth").write_bytes(b"")
got = {it.rel: (it.blurred, it.blurred_self) for it in booth_items(b)}
assert got == {"a.png": (True, True), "b.png": (True, False)}
def test_app_py_never_reads_the_blur_file_itself():
"""Invariant 3, extended from route bodies to the whole module: blur state
is read in `booth_items` and nowhere in app.py. Defeating change: the
second `read_blurred` in build_gallery."""
import ast
src = pathlib.Path(__file__).parent.parent / "booth" / "app.py"
calls = [
n for n in ast.walk(ast.parse(src.read_text()))
if isinstance(n, ast.Call) and getattr(n.func, "id", getattr(n.func, "attr", None)) == "read_blurred"
]
assert calls == []
# ---- the heid bug-hunt on this change (3 arms), folded -------------------------
@pytest.mark.parametrize("line", ['["a.png"]', "[]", "[1,2]"])
def test_a_legacy_line_that_is_valid_json_still_reads_as_a_line(tmp_path, line):
"""3 of 3 arms. Sniffing one file for two formats misread a legacy file
whose ONE line is an item literally named like a JSON array: `["a.png"]`
read as {"a.png"}, un-blurring the item and blurring its neighbour — the bug
this change exists to fix, recreated by its migration. Defeating change:
trying JSON on the legacy file."""
(tmp_path / LEGACY_BLUR_FILE).write_text(line + "\n")
assert read_blurred(tmp_path) == {line}
def test_a_stale_legacy_file_is_silent_once_the_current_one_exists(tmp_path):
(tmp_path / LEGACY_BLUR_FILE).write_text("old.png\n")
(tmp_path / BLUR_FILE).write_text('["new.png"]')
assert read_blurred(tmp_path) == {"new.png"}
def test_a_planted_directory_at_the_blur_file_is_a_refusal_not_a_crash(tmp_path):
"""2 of 3 arms plus a third from another angle: the reader was hardened
against a planted directory, the writer was not, and `os.replace` onto a
directory raised IsADirectoryError through the route. Defeating change:
letting the OSError out of set_blurred."""
(tmp_path / BLUR_FILE).mkdir()
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "a.png", True)
assert (tmp_path / BLUR_FILE).is_dir(), "a planted directory is not ours to remove"
def test_unblurring_under_a_planted_directory_is_not_an_error(tmp_path):
"""Nothing reads as blurred and nothing was asked to be: the reader agrees
with the request, so there is nothing to refuse."""
(tmp_path / BLUR_FILE).mkdir()
assert set_blurred(tmp_path, "a.png", False) == set()
def test_a_planted_directory_at_the_legacy_name_does_not_block_a_write(tmp_path):
(tmp_path / LEGACY_BLUR_FILE).mkdir()
set_blurred(tmp_path, "a.png", True)
assert read_blurred(tmp_path) == {"a.png"}
def test_the_route_answers_a_planted_directory_with_409(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
(b / BLUR_FILE).mkdir()
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": "a.png", "on": "1"}, follow_redirects=False)
assert r.status_code == 409
def test_a_lone_surrogate_in_the_file_is_skipped_and_writes_still_work(tmp_path):
"""hulda, execution-verified: `"\\ud800"` is a valid JSON string no filename
can produce, and the writer's UTF-8 encode raised on it, so one planted
escape froze the booth's blur. Defeating change: keeping every str member."""
(tmp_path / BLUR_FILE).write_text('["\\ud800", "a.png"]')
assert read_blurred(tmp_path) == {"a.png"}
assert set_blurred(tmp_path, "b.png", True) == {"a.png", "b.png"}
@pytest.mark.parametrize("rel", ["", "/abs.png", "a/../b.png", "..", "\ud800.png"])
def test_a_rel_that_is_not_an_item_path_is_refused(tmp_path, rel):
with pytest.raises(ValueError):
set_blurred(tmp_path, rel, True)
assert not (tmp_path / BLUR_FILE).exists()
def test_a_double_dot_inside_a_name_is_an_item_path(tmp_path):
"""A `..` COMPONENT is an escape; `a..b.png` is a filename."""
assert set_blurred(tmp_path, "a..b.png", True) == {"a..b.png"}
def test_the_route_refuses_an_empty_rel(tmp_path):
"""kimi: `f="/"` stripped to "" and was stored as a member no item can have."""
_booth(tmp_path, "g", {"a.png": PNG})
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
r = c.post("/b/g/blur", data={"f": "/", "on": "1"}, follow_redirects=False)
assert r.status_code == 400
assert not (tmp_path / "g" / BLUR_FILE).exists()
def test_the_writer_never_writes_a_set_the_reader_would_refuse(tmp_path, monkeypatch):
"""2 of 3 arms: nothing capped the writer, the reader refuses a file over
the cap and reads it as EMPTY, so the write that crossed it revealed every
item. Defeating change: no size check before the write."""
import booth.blur as blur
set_blurred(tmp_path, "a.png", True)
monkeypatch.setattr(blur, "BLUR_MAX_BYTES", len(b'["a.png"]') + 3)
with pytest.raises(BlurUnwritable):
set_blurred(tmp_path, "bbbbbbbb.png", True)
monkeypatch.setattr(blur, "BLUR_MAX_BYTES", BLUR_MAX_BYTES)
assert read_blurred(tmp_path) == {"a.png"}, "a refused write changed the set"
def test_the_cli_accepts_a_double_dot_inside_a_name(tmp_path):
"""2 of 3 arms: the CLI's `*..*` substring guard refused `a..b.png`, which
the route accepts. One predicate now serves both."""
b = _booth(tmp_path, "g", {"a..b.png": PNG})
r = _cli(tmp_path, "blur", "g", "a..b.png")
assert r.returncode == 0, r.stderr
assert read_blurred(b) == {"a..b.png"}
def test_the_cli_refuses_an_empty_item_path_before_writing(tmp_path):
"""regin: `booth blur g /` stored an empty member. Refused, and a valid
item named alongside it is not written either."""
b = _booth(tmp_path, "g", {"a.png": PNG})
r = _cli(tmp_path, "blur", "g", "a.png", "/")
assert r.returncode == 2
assert read_blurred(b) == set()
def test_the_cli_refuses_a_planted_directory_with_a_message(tmp_path):
b = _booth(tmp_path, "g", {"a.png": PNG})
(b / BLUR_FILE).mkdir()
r = _cli(tmp_path, "blur", "g", "a.png")
assert r.returncode == 3
assert "Traceback" not in r.stderr and BLUR_FILE in r.stderr
def test_the_cli_fails_closed_without_its_package(tmp_path):
"""kimi: the `link` verb says why and exits 3 when booth/ is missing; the
`blur` verb died with a bare traceback. Same deployment shape as
test_cli's link test: the script alone, no package beside it."""
b = _booth(tmp_path, "g", {"a.png": PNG})
lone = tmp_path / "lone" / "scripts"
lone.mkdir(parents=True)
(lone / "booth").write_text(SCRIPT.read_text())
(lone / "booth").chmod(0o755)
env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"}
env.update(BOOTH_DATA_DIR=str(tmp_path), BOOTH_URL="http://booth.invalid")
r = subprocess.run([str(lone / "booth"), "blur", "g", "a.png"], capture_output=True,
text=True, env=env, cwd="/tmp", timeout=30)
assert r.returncode == 3
assert "Traceback" not in r.stderr
assert read_blurred(b) == set()
def test_a_fifo_at_the_legacy_name_does_not_block_the_read(tmp_path):
os.mkfifo(tmp_path / LEGACY_BLUR_FILE)
assert _within(5, lambda: read_blurred(tmp_path)) == set()
+7 -1
View File
@@ -695,4 +695,10 @@ def test_unblurring_the_booth_keeps_per_item_choices(booth):
run(data, "unblur", "b") run(data, "unblur", "b")
assert not (b / ".blurbooth").exists() assert not (b / ".blurbooth").exists()
assert (b / ".blurred").read_text().strip() == "a.png" # Read through the reader, not the bytes: `.blurred` became a JSON array
# (the round-trip fix, operator-ruled 2026-09-23), and this test is about
# the per-item choice surviving, not about the file's format.
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.blur import read_blurred
assert read_blurred(b) == {"a.png"}
+1 -1
View File
@@ -278,7 +278,7 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) -------------------------------------- # ---- the stdlib-only invariant (INV-5) --------------------------------------
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "__init__"]) @pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest", "benches", "blur", "__init__"])
def test_stdlib_only(module): def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv, """INV-5. scripts/booth imports these under the system python3 with NO venv,
through a `python3 -c` heredoc that no AST extractor can see — so nothing through a `python3 -c` heredoc that no AST extractor can see — so nothing