groa's late retry on the blur bug-hunt, adjudicated against the landed code. Its four bugs were already fixed, but a robustness note (mkstemp's 0600 locks out a reader under another uid, which then "sees nothing and replaces it") pointed at a real gap. set_blurred built on read_blurred, the renderer's lenient reader, which turns an unreadable, oversized or malformed `.blurred.json` into an empty set. The writer then replaced the file, and whatever it held was gone. This is the `.marks.json` wipe of 2026-09-21 in a new module, and it shipped for a night. - `_load` is the one parse with two postures. read_blurred maps its refusal to "nothing blurred" (a damaged file costs the blur, never the page). set_blurred lets it raise BlurUnwritable, which the route answers with 409 and the CLI with exit 3, and changes nothing. - It refuses only for a REGULAR file it cannot read. A link, a directory or a FIFO at either name holds no set anyone wrote, so it reads as empty, and the postcondition judges whether the write can land: a link is replaced, a directory refused. - The file is 0644 again, as the line-format writer left it (fchmod after mkstemp). The open flags in `_read_capped` became a second layer behind the new lstat check, and the mutation run caught their rows VACUOUS through the public API. They are now held to account by direct tests, because they still close the lstat-to-open race. blur_storage.toml: 25/25. No second panel was run: this folds one reviewer note plus the repo's own recorded lesson, with a test and a proved row for each behaviour.
233 lines
10 KiB
Python
233 lines
10 KiB
Python
"""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 _load(booth: Path) -> set[str]:
|
|
"""The blur set, STRICTLY: raises BlurUnwritable for a REGULAR file at
|
|
either name that cannot be read as its format (a permission, over the size
|
|
cap, not JSON), where `read_blurred` would say "nothing blurred". The
|
|
writer builds on this; the renderer on the lenient one. One parse, two
|
|
postures, so they cannot disagree about what a file means, only about what
|
|
to do when it cannot be read.
|
|
|
|
Only a regular file can hold a set anyone wrote. A link, a directory or a
|
|
FIFO at either name holds nothing to lose, so it reads as empty here too,
|
|
and whether the write can then land is `set_blurred`'s postcondition to
|
|
judge (a link is replaced; a directory is refused).
|
|
|
|
ANYTHING at `.blurred.json` 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: no one could have meant them, and dropping them loses
|
|
nothing.
|
|
"""
|
|
current, legacy = booth / BLUR_FILE, booth / LEGACY_BLUR_FILE
|
|
for path in (current, legacy):
|
|
try:
|
|
st = os.lstat(path)
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError as exc:
|
|
raise BlurUnwritable(f"cannot stat {path.name} in {booth.name!r} ({exc})") from exc
|
|
if not stat.S_ISREG(st.st_mode):
|
|
return set()
|
|
raw = _read_capped(path)
|
|
if raw is None:
|
|
raise BlurUnwritable(f"{path.name} in {booth.name!r} is not a readable file of sane size")
|
|
text = raw.decode("utf-8", "surrogateescape")
|
|
if path is legacy:
|
|
return {ln.strip() for ln in text.splitlines() if ln.strip()}
|
|
try:
|
|
data = json.loads(text)
|
|
except (ValueError, RecursionError) as exc:
|
|
# RecursionError: a deeply nested array blows the parser's stack,
|
|
# and it is neither a ValueError nor an OSError (the `.seen` hole).
|
|
raise BlurUnwritable(f"{BLUR_FILE} in {booth.name!r} is not JSON") from exc
|
|
if not isinstance(data, list):
|
|
raise BlurUnwritable(f"{BLUR_FILE} in {booth.name!r} is not a JSON array")
|
|
return {r for r in data if isinstance(r, str) and r and _encodable(r)}
|
|
return set()
|
|
|
|
|
|
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. A damaged
|
|
file costs the blur, never the page. The WRITER does not get this leniency;
|
|
see `_load`.
|
|
"""
|
|
try:
|
|
return _load(booth)
|
|
except BlurUnwritable:
|
|
return set()
|
|
|
|
|
|
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.
|
|
|
|
WRITES ARE STRICT. The set it builds on comes from `_load`, which refuses
|
|
(BlurUnwritable, nothing changed) where the renderer's reader would say
|
|
"nothing blurred": a file it cannot read is never overwritten with a set
|
|
that forgot what it held.
|
|
|
|
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)
|
|
# STRICT, never `read_blurred`: an empty set from a file that could not be
|
|
# read would be written back over it, and whatever it held would be gone
|
|
# (the `.marks.json` wipe of 2026-09-21; groa: a cross-uid EACCES).
|
|
current = _load(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:
|
|
# mkstemp makes 0600; the line-format writer left 0644, and a
|
|
# reader under another uid must still see the set (groa).
|
|
os.fchmod(fd, 0o644)
|
|
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
|