Files
booth/tests/test_blur.py
T
vh c1f5543b77 fix(blur): fold the heid bug-hunt: two file names, a reader-judged writer, one predicate
The heid bug-hunt panel on 4cfbce5 (hulda, regin, kimi; groa timed out) found
four real defects in the round-trip fix, and three of its arms converged on the
worst: it re-created the bug it existed to fix.

- Two names, never a sniffed file (3/3). JSON went into the OLD `.blurred`, and
  the reader guessed the format from the bytes, so a legacy file whose one line
  is an item named `["a.png"]` read as {"a.png"} and blurred the neighbour. The
  set now lives in `.blurred.json`, JSON only. The legacy `.blurred` is read as
  lines only, and only while `.blurred.json` is absent; the first write retires
  it, after the new file is in place.
- A planted directory is a 409, not a 500 (2/3 plus a third angle, executed by
  the seat). The reader was hardened against it and the writer was not:
  os.replace and unlink raised IsADirectoryError through the route. Now the
  writer is judged by its reader: set_blurred re-reads after writing and raises
  BlurUnwritable unless the set on disk is the set asked for. That one check
  covers a directory at either name, a permission and a race.
- A lone surrogate is dropped on read (hulda, executed). `"\ud800"` is a valid
  JSON string that no filename can produce, and the UTF-8 encode raised on it
  at every later write.
- The writer respects the reader's size cap (2/3). Nothing capped the write,
  and the reader reads an oversized file as EMPTY, which reveals everything.
- One predicate, check_rel, for the route and the CLI (2/3). The CLI's `*..*`
  substring guard refused `a..b.png`, which the route accepts. It also refuses
  an empty path now (regin, kimi), and every item is checked before any is
  written.
- `booth blur` fails closed, with a message and exit 3, when its package is
  missing (kimi), as `link` already does.

Declined, with reasons: the Item positional-constructor break (booth_items is
the only constructor, INV-1), the fdopen fd leak and the short read (not
constructible on a local filesystem, and the `.seen` shape), and
unreadable-reads-as-revealed (blur is cosmetic; the `.seen` posture).
blur_storage.toml: 20/20 proved. One row came back VACUOUS on its first run,
because `set() or X` is X, and was rewritten before counting.
2026-09-23 23:01:18 -07:00

378 lines
16 KiB
Python

"""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()