Skip to content

fix(adopt): never let a same-millisecond backup overwrite an earlier one - #3

Merged
fylorn merged 1 commit into
devfrom
fix/adopt-backup-collision
Sep 14, 2026
Merged

fylorn merged 1 commit into
devfrom
fix/adopt-backup-collision

Conversation

@fylorn

@fylorn fylorn commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

The bug

re_adopting_does_not_overwrite_what_the_original_was in crates/tw-adopt/tests/roundtrip.rs is flaky: 10 of 30 local runs failed on unmodified dev (6ac917c), and it failed CI on #2, which only touched Cargo.toml descriptions.

It is not a test problem. backup_to in crates/tw-adopt/src/foreign.rs put backups at backups/<now_ms>/<flattened real path>. When the same file is adopted twice within one millisecond:

  1. First adopt backs up the user's file (with sk-ant-…, their own key) to backups/<ms>/….
  2. Second adopt backs up the file as it is now — already holding our tw-… key — to the same path, overwriting step 1.
  3. The sidecar record still points at that path, so plan_restore / apply_restore recover the secret original from the overwritten backup and restore the user's config with our key. Their original key is lost.

The fix

  • Backup directories are named <ms>-<seq:04> and claimed with std::fs::create_dir (not create_dir_all); on AlreadyExists the sequence number is bumped. The filesystem decides atomically, so concurrent or back-to-back backups can't share a directory.
  • The backup file is opened with create_new(true): an existing backup is never overwritten, we fail instead.
  • The property documented on backup_root — sorting directory names gives chronological order — still holds: the sequence is zero-padded, and legacy <ms> directories sort before <ms>-NNNN.
  • Checked for anything parsing backup directory names as integers (tw-adopt, tw-api, tw-control, tw-store, and ThinkWatch Lite): nothing does. Callers only carry the full backup path as a string.

Tests

  • two_backups_in_the_same_millisecond_do_not_overwrite_each_other: pins two backups of the same file to the same timestamp (via a new backup_at that takes ms), asserts both exist with their own contents, and that name order is chronological across milliseconds.
  • back_to_back_real_backups_all_survive: 50 backups on the real clock, all intact.
  • cargo test -p tw-adopt --test roundtrip re_adopting_does_not_overwrite_what_the_original_was × 100: 0 failures.
  • cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace: all pass.

🤖 Generated with Claude Code

backup_to named the backup directory after the current millisecond and
the file inside after the flattened real path. Two adoptions of the same
file within one millisecond therefore wrote to the exact same backup
path. The second backup already contains our gateway key, so it replaced
the first backup holding the user's original key. The sidecar still
pointed at that path, so restore recovered our key instead of the user's
and their original secret was gone. This is what made
re_adopting_does_not_overwrite_what_the_original_was flaky (~1 in 3
local runs), but the data loss is real, not just a test artefact.

Directories are now named `<ms>-<seq:04>` and claimed with create_dir
(not create_dir_all), bumping seq on AlreadyExists, so the filesystem
decides atomically who gets a name. The file inside is opened with
create_new so an existing backup is never overwritten. Zero-padding
keeps "sort by name = chronological order"; old plain `<ms>` directories
sort before any `<ms>-NNNN`, and nothing parses the names as integers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 5a9f839 into dev Sep 14, 2026
1 check passed
@fylorn
fylorn deleted the fix/adopt-backup-collision branch September 14, 2026 02:39
fylorn added a commit that referenced this pull request Sep 14, 2026
…one (#3)

backup_to named the backup directory after the current millisecond and
the file inside after the flattened real path. Two adoptions of the same
file within one millisecond therefore wrote to the exact same backup
path. The second backup already contains our gateway key, so it replaced
the first backup holding the user's original key. The sidecar still
pointed at that path, so restore recovered our key instead of the user's
and their original secret was gone. This is what made
re_adopting_does_not_overwrite_what_the_original_was flaky (~1 in 3
local runs), but the data loss is real, not just a test artefact.

Directories are now named `<ms>-<seq:04>` and claimed with create_dir
(not create_dir_all), bumping seq on AlreadyExists, so the filesystem
decides atomically who gets a name. The file inside is opened with
create_new so an existing backup is never overwritten. Zero-padding
keeps "sort by name = chronological order"; old plain `<ms>` directories
sort before any `<ms>-NNNN`, and nothing parses the names as integers.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant