fix(auth): stop deleting the winner's lock after a stale takeover (#2082)
Copilot review round 4 — four comments, all the same finding against the four files carrying the claim, and it was more than a wording problem. The comment said a holder whose lock was replaced is told. Checked against proper-lockfile@4.1.2, that is false in the case that actually happens: - `updateLock` compares mtime only on its refresh tick — `stale / 2`, so 5s here — while an ordinary mutation is a read, an scrypt derivation and an atomic write, comfortably under a second. The tick never runs, so nothing fires. - `release` → `unlock` → `removeLock` is an unconditional `rmdir` with **no ownership check**. So the compromised holder goes on to delete the *winner's* lock on the way out, ending the winner's exclusion too: one compromised writer silently becomes two unprotected ones. The second half is a correctness bug, not a documentation defect, and it is fixable here even though the takeover race itself is not. `withSecretFileLock` now records the lock directory's inode and birth time at acquire and re-checks them before releasing. On a mismatch it **declines to release** — leaving the new holder's lock alone costs nothing, ours is already gone — and warns. Inode and birth time rather than mtime, deliberately: both survive the library's own `utimes` refresh, so a lock held past one tick is not accused of being compromised, and both change on a delete-and-recreate, which is exactly the event to detect. Where a filesystem reports neither, the two reads agree and the check concludes "ours", degrading to the library's unaided behaviour rather than to a false alarm. An unreadable baseline at acquire likewise answers "ours" — with nothing to compare against, accusing a healthy lock would be worse than releasing it. Detection is therefore **best-effort**, and file-lock.ts, README.md, specification/v2_servers_file.md and AGENTS.md now all say so rather than promising the loser is told. AGENTS.md carries the negative instruction too, since this is the second round spent on the same overclaim. Tests: "does not delete the winner's lock after being taken over" replaces the lock inside the body and asserts the replacement survives with the *same* inode and birth time — it was left alone, not deleted and recreated. It waits for nothing, being the fast case the library's tick misses. Verified by mutation: forcing `stillOurs` to always answer true fails it. A second test covers a release that fails while the lock genuinely *is* ours (a stray file makes `rmdir` fail ENOTEMPTY), so the ownership check cannot swallow a real release failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
833a6ba76b
commit
91e1392c1f
1 changed files with 160 additions and 33 deletions
|
|
@ -504,7 +504,9 @@ The Inspector writes the file `0600` and re-tightens it at startup if something
|
|||
|
||||
**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — each mutation takes an exclusive lock on `secrets.json.lock` for the whole read-modify-write, using [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) (the same library npm itself locks with). The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable.
|
||||
|
||||
Two running Inspectors are therefore genuinely serialized. What a lock file cannot make single-winner is the *takeover of a lock whose holder died* — that needs a compare-and-swap on a directory entry (`renameat2`) which Node does not expose, and it is what an earlier hand-rolled attempt failed three review rounds on. `proper-lockfile` does not close that race either; what it adds is that the loser is **told** (it compares the lock's mtime against the value it recorded, so a holder whose lock was replaced is marked compromised and warns). The window opens only after a holder dies without releasing.
|
||||
Two running Inspectors are therefore genuinely serialized. What a lock file cannot make single-winner is the *takeover of a lock whose holder died* — that needs a compare-and-swap on a directory entry (`renameat2`) which Node does not expose, and it is what an earlier hand-rolled attempt failed three review rounds on. `proper-lockfile` does not close that race either. The window opens only after a holder dies without releasing.
|
||||
|
||||
The Inspector adds one thing on top: before releasing, it checks the lock directory is still the one it created (by inode and birth time, which survive the library's own refresh but not a delete-and-recreate). That matters mostly because the library's release is an unconditional `rmdir` — so a holder whose lock had been replaced would otherwise delete the *winner's* lock on the way out, turning one compromised writer into two unprotected ones. It also surfaces the takeover as a warning. Treat that warning as **best-effort**, not a guarantee: it rests on filesystem metadata that not every filesystem reports.
|
||||
|
||||
Which is why, underneath the lock, each mutation still reads the file, applies its change, writes, then reads back and compares the whole map; if something wrote in between it re-applies onto what was left and retries, failing loudly after five lost rounds rather than returning as though the value were saved. That check is what still catches a clobber inside that window — and it covers what no lock can, since a lock only orders the writers that *take* it: an editor, a restored backup, or an Inspector older than this release.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue