Skip to content

fix(recorder): storage-migration same-file guard — never unlink the only copy (#276) - #282

Merged
badbread merged 1 commit into
mainfrom
fix/migration-same-file-guard
Jul 20, 2026
Merged

fix(recorder): storage-migration same-file guard — never unlink the only copy (#276)#282
badbread merged 1 commit into
mainfrom
fix/migration-same-file-guard

Conversation

@badbread

Copy link
Copy Markdown
Owner

Fixes #276P0 footage-loss finding (F1) from the 2026-07-19 adversarial recorder audit. The #70 failure class, resurrected in the "Change storage" migration path.

The bug, reproduced at runtime

When the from/to storage rows resolve to the same file (duplicate rows for one path — storages has UNIQUE(name) but not UNIQUE(path) — or symlinked/bind-mounted roots), copy_with_crc32's same-file bail routed into the copy-failure arm, whose cleanup ran remove_file(dst)and dst IS src. The defense against truncating the only copy became an outright unlink of the only copy: the policy's oldest MIGRATION_BATCH (256) segments destroyed per attempt, silently, with each retry eating the next 256.

Runtime-proven, not just traced: the new regression test run against the pre-fix code fails with the files destroyed; with the fix it passes.

The fix

  • Pre-guard: same_file(src, dst) before the copy → flip the DB row in place with zero file I/O (mirrors move_segment_to_archive's in-place flip). A same-path migration is now the safe consolidation it looks like — rows repoint, files untouched, progress recorded (no false "stalled").
  • Cleanup: an in-place segment deletes nothing in either arm (flipped or lost-the-race — the "source" and "destination" are one inode).
  • Defense in depth: the copy-failure arm re-verifies !same_file before removing the partial dst, enforcing the audit's phase invariant — the unguarded copy phase never deletes a file it did not itself create — even across a rename/symlink swap between guard and copy.

Verification

  • New test change_storage_drain_same_path_flips_in_place (duplicate storage row on one dir → drain → files exist, rows flipped, progress = 2, idempotent re-run). Fails on pre-fix code, passes on fix.
  • Existing change_storage_drain_relocates_footage (the normal path) still green.
  • Full gate: fmt ✅ clippy -D warnings ✅ cargo test --workspace ✅ (259 recorder tests).

Operator note

Until this is deployed, do not run a policy change-storage migration on any install that might carry duplicate storage rows for one directory.

…unlink the only copy

The 'Change storage' drain's copy-failure cleanup ran remove_file(dst) — but
when the from/to storage rows resolve to the SAME file (duplicate rows for one
path: storages has UNIQUE(name), not UNIQUE(path); symlinked roots alias too),
copy_with_crc32's same-file bail routed into that failure arm and dst IS src:
the #70 defense against truncating the only copy became an outright unlink of
the only copy, destroying the policy's oldest MIGRATION_BATCH segments per
attempt (audit finding F1, issue #276).

- Pre-guard: same_file(src, dst) before the copy -> flip the DB row in place
  with NO file I/O (mirrors move_segment_to_archive's in-place flip). A
  same-path migration is now the safe, useful consolidation it looks like.
- Cleanup: an in-place segment deletes NOTHING in either arm (flipped: the row
  points at the same bytes under the new root; lost-the-race: the file is
  still the live source).
- Defense in depth: the copy-failure arm re-verifies !same_file before
  removing the partial dst, enforcing the phase invariant 'the unguarded copy
  phase never deletes a file it did not itself create' even across a
  rename/symlink swap between guard and copy.

Regression test change_storage_drain_same_path_flips_in_place: duplicate
storage row on one directory, drain between them -> files untouched, rows
flipped, progress recorded, second run a no-op. Verified the test FAILS
against the pre-fix code (files destroyed) and passes with the fix — the
audit's static trace reproduced at runtime.

Fixes #276

Signed-off-by: badbread <badbread@users.noreply.github.com>
@badbread
badbread merged commit cfdcfd8 into main Jul 20, 2026
7 checks passed
@badbread
badbread deleted the fix/migration-same-file-guard branch July 20, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recorder: storage-migration copy-failure cleanup deletes the SOURCE when from/to resolve to the same file (footage loss)

1 participant