inject: per-target exposure lock — refuse overlapping -r runs - #7
Conversation
Two rapid `vt inject -r FILE` runs left the plaintext on disk forever:
the second run read the first run's exposed plaintext and snapshotted
it as its "ciphertext" backup, and its supervisor — firing last —
renamed that plaintext back over the target, with no backup or sidecar
left for --recover to find.
The ciphertext backup's name is now deterministic per target
(`.{name}.vt-backup`), so the existing O_CREAT|O_EXCL create doubles
as a per-target exposure lock: it exists exactly while an exposure
window is open (created before any plaintext hits disk, consumed by
rename() on every restore path). A second inject of the same file
fails EEXIST and is refused with guidance picked from the sidecar:
window still live (retry shortly), supervisor dead (run `vt inject
--recover`), or untracked (manual mv). A pre-decrypt check refuses
before spending a Touch ID / phone approval, and a `-r` file with
zero vt:// records is refused outright — nothing to decrypt means
the wrong file, or plaintext left by a broken exposure.
Codex Review —
|
The deterministic backup path made the sidecar ambiguous in the other direction: a stale record (crash between a restore's rename and the sidecar's removal) could pair with a LATER exposure's backup at the same path, and a delayed supervisor (suspend pauses its monotonic sleep while the wall-clock deadline lapses) could blindly rename a successor's backup — either way consuming the only ciphertext copy and, in the worst interleaving, stranding plaintext with no recovery record. Four guards, from review: - every new sidecar records its backup's (dev, ino); arming aborts before any plaintext if the id cannot be captured - recovery re-checks the id plus an mtime ordering bound (a legitimate backup always predates its own deadline) — the bound is all an id-less legacy record has - arming retires stale sidecars naming the path while the O_EXCL lock is held - the supervisor receives dev:ino in its argv and renames only the generation it armed for, keeping the sidecar when the restore fails or the backup's state is unknowable Also hardened: sidecar reads are bounded (O_NOFOLLOW|O_NONBLOCK, regular-file check, 128 KiB cap shared with the write side, so an over-cap record fails loudly at arm time instead of being skipped at recovery); only "not there" counts as backup-consumed — other stat errors keep the recovery record; orphan guidance for a no-home-dir fallback sidecar says manual restore instead of naming --recover, which never scans there; sidecar write failures warn on stderr.
Codex Review — update (
|
Codex review follow-ups on the exposure-lock series: - The parent's step-4/5/exec failure paths called immediate_restore(), a blind rename of the deterministic backup path. A parent suspended past its own window could resume into a NEWER exposure owning that path and consume its backup — stranding that run's plaintext with no restore source. Those paths now share the supervisor's restore body (renamed restore_exposure): rename only the (dev, ino) generation this run armed, and keep the sidecar for --recover when a restore fails or the backup's state is unknowable. - A backup write failing part-way (ENOSPC/EIO) left a truncated .vt-backup with no sidecar or supervisor; the next inject would read it as an untracked exposure lock and suggest moving the partial copy over the intact ciphertext. create_exposure_backup() now removes the just-created file on any post-create failure, while propagating EEXIST untouched — that file is another exposure's live lock.
Codex Review — update (
|
Bug
Two rapid consecutive
vt inject -r FILEruns leave the decrypted plaintext on disk permanently, with nothing left for--recoverto find:Both backups are consumed and both sidecars deleted, so the exposure is silent and
vt inject --recovercannot undo it.Fix
Make the ciphertext backup's name deterministic per target (
.{name}.vt-backup, previously random-suffixed). The existingO_CREAT|O_EXCL|O_NOFOLLOWcreate now doubles as a per-target exposure lock:rename()on every restore path (supervisor,immediate_restore,--recover);inject -rof the same file failsEEXISTand is refused, with guidance picked from the sidecar: window still live (retry shortly), supervisor dead (runvt inject --recover), or untracked backup (manualmv);Two supporting guards:
O_EXCLcreate remains the authoritative race-free gate);-rfile containing zerovt://records is refused outright — nothing to decrypt means the wrong file, or plaintext left behind by a broken exposure.Old random-suffixed sidecars/backups from before this change remain recoverable via
--recover(it restores by the full paths recorded in the sidecar).Testing
cargo test: 301 passed.cargo check(host) clean; the linux-gnu cross-check fails locally only for a missing cross toolchain (pre-existing on clean main).🤖 Generated with Claude Code