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:
vh
2026-09-24 00:56:25 -07:00
parent 7d4a26f486
commit 8a78a9bd1d
5 changed files with 220 additions and 57 deletions
+10 -4
View File
@@ -93,10 +93,16 @@ JSON and blurs the neighbour, the bug being fixed (heid bug-hunt, 3 of 3). So
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.
**Reads lenient, writes strict; and a writer is judged by its reader.** The
renderer's `read_blurred` turns anything it cannot read into an empty set,
because a damaged file must cost the blur and never the page. The writer
builds on `_load`, the same parse, which REFUSES instead: a regular file it
cannot read (a permission, over the cap, not JSON) is never overwritten with a
set that forgot what it held. That is the `.marks.json` wipe again, and the
blur writer shipped without the guard for a night. After writing,
`set_blurred` re-reads and raises `BlurUnwritable` unless the reader returns
exactly the set asked for. The route answers either refusal with 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