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:
@@ -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,50 @@ 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` (the
|
||||||
rel per line — ⚠ see below), `.seen` (R2: rels looked at full size, a JSON
|
per-item blur set, a JSON ARRAY — see below), `.seen` (R2: rels looked at full
|
||||||
ARRAY), `.blurbooth` (the whole booth fogged — a MARKER like `.forever`, not
|
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
|
||||||
an `S_ISREG` check — a planted symlink is refused and a FIFO cannot hang the
|
`.blurred` now matches it (`booth/blur.py`). The open flags mean a planted
|
||||||
read, which is the outage in
|
symlink is refused and a FIFO cannot hang the read, which is the outage in
|
||||||
`persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new dotfile
|
`persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md`. **Any new dotfile
|
||||||
inherits that shape, not `.blurred`'s.** `.blurred` itself is unfixed and
|
inherits that shape.** `.blurred`'s reader still accepts the old line format,
|
||||||
pre-existing.
|
so a booth written before the change keeps its blur until its next write
|
||||||
|
upgrades the file. Do not remove that fallback while a line-format file can
|
||||||
|
still exist.
|
||||||
|
|
||||||
|
**A dotfile with two writers has ONE implementation of the writer.** `.blurred`
|
||||||
|
is written by the service and by `booth blur`, and both call
|
||||||
|
`booth.blur.set_blurred`; the CLI used to keep a grep/printf writer of its own,
|
||||||
|
and two writers of one format is how the formats drift apart.
|
||||||
|
|
||||||
### 3. One resolver for item facts
|
### 3. One resolver for item facts
|
||||||
|
|
||||||
|
|||||||
+10
-25
@@ -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 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
|
||||||
@@ -2234,8 +2217,10 @@ def create_app(
|
|||||||
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
|
# Guard the path the same way the file route must: a blur entry is only
|
||||||
# ever a booth-relative path, never an escape.
|
# ever a booth-relative path, never an escape. NEVER STRIPPED: " a.png"
|
||||||
rel = f.strip().lstrip("/")
|
# 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:
|
if ".." in Path(rel).parts:
|
||||||
raise HTTPException(status_code=400, detail="bad item path")
|
raise HTTPException(status_code=400, detail="bad item path")
|
||||||
set_blurred(booth, rel, on not in ("0", "false", ""))
|
set_blurred(booth, rel, on not in ("0", "false", ""))
|
||||||
|
|||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
"""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
|
||||||
+10
-10
@@ -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.
|
||||||
|
|||||||
+30
-16
@@ -19,22 +19,36 @@ 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
|
- ✅ **`.blurred` ROUND-TRIPS ANY REL** (operator: "fix the blur"). A JSON array
|
||||||
different tests, one failure each, all passing in isolation. Two real defects
|
via stdlib-only `booth/blur.py`, the one writer for both the service and
|
||||||
were fixed chasing it and NEITHER is proven to be the cause. **Do not read a
|
`booth blur`. The legacy line format is still read, and a write upgrades it.
|
||||||
green suite as proof.** Operator ruled design-dev diagnoses it properly.
|
The bug design-dev's bug-hunt found (a stripped rel blurring its neighbour)
|
||||||
|
had no live victims: 6 `.blurred` files, 42 rels, 0 with edge whitespace.
|
||||||
|
`Item.blurred_self` came along, so blur state has one reader (invariant 3).
|
||||||
|
**Still open and ours, not done:** the "off"-means-ON idiom drift between
|
||||||
|
/blur, /blurbooth and /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.
|
||||||
|
|||||||
+20
-12
@@ -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,33 @@ case "$cmd" in
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
for item in "$@"; do
|
||||||
item="${item#"$DATA/$b/"}"; item="${item#/}"
|
item="${item#"$DATA/$b/"}"; item="${item#/}"
|
||||||
case "$item" in
|
case "$item" in
|
||||||
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
|
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
|
||||||
esac
|
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` is a JSON array now (a rel may carry a leading
|
||||||
# anything here is blurred at all.
|
# space or a newline, and the old line format could not round-trip it), and
|
||||||
[ -s "$f" ] || rm -f -- "$f"
|
# 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
|
if [ "$cmd" = blur ]; then
|
||||||
echo "blurred (cosmetic — still served): $URL/b/$b/"
|
echo "blurred (cosmetic — still served): $URL/b/$b/"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# 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 = '''
|
||||||
|
data = None'''
|
||||||
|
new = '''
|
||||||
|
return set()'''
|
||||||
|
|
||||||
|
[[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(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
|
||||||
|
new = '''
|
||||||
|
fd = os.open(booth / BLUR_FILE, 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(booth / BLUR_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)'''
|
||||||
|
new = '''
|
||||||
|
fd = os.open(booth / BLUR_FILE, 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"
|
||||||
|
file = "scripts/booth"
|
||||||
|
test = "tests/test_blur.py::test_the_cli_still_refuses_a_dotdot_path"
|
||||||
|
old = '''
|
||||||
|
*..*) echo "refusing path with '..': $item" >&2; exit 2 ;;'''
|
||||||
|
new = '''
|
||||||
|
*..*) echo "refusing path with '..': $item" >&2 ;;'''
|
||||||
|
|
||||||
|
[[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):'''
|
||||||
@@ -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,'''
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""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). It is
|
||||||
|
now a JSON array, the `.seen` shape, read without following a link or blocking
|
||||||
|
on a FIFO. The old line format is still READ, so nothing live changes until the
|
||||||
|
next write upgrades it.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.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 / 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 / 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):
|
||||||
|
(tmp_path / 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"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 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 == []
|
||||||
+7
-1
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user