fix(blur): writes are strict, so a set the writer cannot read is never overwritten
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.
This commit is contained in:
+69
-1
@@ -266,7 +266,8 @@ def test_a_planted_directory_at_the_blur_file_is_a_refusal_not_a_crash(tmp_path)
|
||||
|
||||
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."""
|
||||
with the request, so there is nothing to refuse. A directory holds no set,
|
||||
so strict writes (below) have nothing to protect here."""
|
||||
(tmp_path / BLUR_FILE).mkdir()
|
||||
assert set_blurred(tmp_path, "a.png", False) == set()
|
||||
|
||||
@@ -375,3 +376,70 @@ def test_the_cli_fails_closed_without_its_package(tmp_path):
|
||||
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()
|
||||
|
||||
|
||||
# ---- reads lenient, writes strict (groa's retry, and marks' lesson) ------------
|
||||
#
|
||||
# The reader turns anything it cannot read into an EMPTY set, which is right for
|
||||
# rendering: a damaged file costs the blur, never the page. A writer that builds
|
||||
# on that empty set then replaces the file, and whatever it could not read is
|
||||
# gone. That is the `.marks.json` wipe of 2026-09-21
|
||||
# (persistent-memory.d/2026-09-21-marks-write-wiped-judgment.md), and a
|
||||
# cross-uid reader that got EACCES would do it here (groa).
|
||||
|
||||
|
||||
def test_an_unreadable_blur_file_is_never_overwritten(tmp_path):
|
||||
"""Defeating change: set_blurred building on the lenient reader."""
|
||||
set_blurred(tmp_path, "a.png", True)
|
||||
before = (tmp_path / BLUR_FILE).read_bytes()
|
||||
os.chmod(tmp_path / BLUR_FILE, 0)
|
||||
try:
|
||||
with pytest.raises(BlurUnwritable):
|
||||
set_blurred(tmp_path, "b.png", True)
|
||||
finally:
|
||||
os.chmod(tmp_path / BLUR_FILE, 0o644)
|
||||
assert (tmp_path / BLUR_FILE).read_bytes() == before
|
||||
|
||||
|
||||
def test_a_malformed_blur_file_is_never_overwritten(tmp_path):
|
||||
(tmp_path / BLUR_FILE).write_text("not json at all")
|
||||
with pytest.raises(BlurUnwritable):
|
||||
set_blurred(tmp_path, "a.png", True)
|
||||
assert (tmp_path / BLUR_FILE).read_text() == "not json at all"
|
||||
|
||||
|
||||
def test_an_oversized_blur_file_is_never_overwritten(tmp_path, monkeypatch):
|
||||
import booth.blur as blur
|
||||
set_blurred(tmp_path, "a.png", True)
|
||||
before = (tmp_path / BLUR_FILE).read_bytes()
|
||||
monkeypatch.setattr(blur, "BLUR_MAX_BYTES", 4)
|
||||
with pytest.raises(BlurUnwritable):
|
||||
set_blurred(tmp_path, "b.png", False)
|
||||
assert (tmp_path / BLUR_FILE).read_bytes() == before
|
||||
|
||||
|
||||
def test_the_blur_file_is_world_readable_as_it_always_was(tmp_path):
|
||||
"""groa: mkstemp creates 0600, where the line-format writer left 0644, so a
|
||||
reader under another uid saw nothing. Defeating change: no chmod."""
|
||||
set_blurred(tmp_path, "a.png", True)
|
||||
assert (tmp_path / BLUR_FILE).stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
# The open flags are the SECOND layer: `_load` lstat-checks for a regular file
|
||||
# first, so a FIFO or a link never reaches `os.open` through the public API, and
|
||||
# a mutation run found the flags VACUOUS there. They still close the race (a
|
||||
# file swapped for a FIFO or a link between the lstat and the open), so they
|
||||
# are held to account directly, where nothing stands in front of them.
|
||||
|
||||
|
||||
def test_the_raw_read_never_blocks_on_a_fifo(tmp_path):
|
||||
from booth.blur import _read_capped
|
||||
os.mkfifo(tmp_path / "f")
|
||||
assert _within(5, lambda: _read_capped(tmp_path / "f")) is None
|
||||
|
||||
|
||||
def test_the_raw_read_never_follows_a_link(tmp_path):
|
||||
from booth.blur import _read_capped
|
||||
(tmp_path / "real.json").write_text('["a.png"]')
|
||||
(tmp_path / "link").symlink_to(tmp_path / "real.json")
|
||||
assert _read_capped(tmp_path / "link") is None
|
||||
|
||||
Reference in New Issue
Block a user