feat: safer database recovery — backup corrupt diskdeck.db before delete - #10
Conversation
…ith timestamped .bak + .txt sidecar - Extract backup_and_remove_corrupt_db helper that only deletes after successful copy + best-effort sidecar - Call from both recovery branches in DiskStore::new (user_version failure and initial open failure) - If backup fails, return Database error and leave original file untouched - Enhance test_corrupt_db_gets_replaced to assert .bak and sidecar are created - Updated module docs to document the new parachute behavior This directly addresses #5: never lose user data (disk credentials etc.) without a recoverable artifact. Closes #5
There was a problem hiding this comment.
Pull request overview
This PR improves DiskDeck’s database recovery safety by backing up diskdeck.db before deleting it when the DB cannot be opened/decrypted, providing a post-mortem recovery path while keeping the normal “start fresh” behavior.
Changes:
- Adds a helper to copy the corrupt DB to a timestamped
.bak(and write a.bak.txtsidecar) before removing the original. - Updates the two recovery paths in
DiskStore::newto use the backup-first behavior. - Extends the corrupt-DB test to assert that backup artifacts are created.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS>.bak.txt` (sidecar explaining the | ||
| //! incident, timestamp, and how to attempt manual restore by renaming back) | ||
| //! | ||
| //! The backup is written to the **same directory** as the original. Only after | ||
| //! the backup (and sidecar) succeeds is the corrupt `diskdeck.db` removed and a | ||
| //! fresh database created. This gives users and support staff a post-mortem | ||
| //! recovery path without changing the "start fresh on irrecoverable key/DB" | ||
| //! safety posture. | ||
| //! | ||
| //! If the backup copy fails for any reason (e.g. disk full), the original file | ||
| //! is left untouched and a clear `DiskDeckError` is returned instead of deleting. | ||
| //! |
There was a problem hiding this comment.
Thank you for catching the documentation inconsistency. We have updated the module-level docs to accurately reflect the implementation: the .bak copy is mandatory before deletion, while the sidecar .txt is explicitly best-effort and does not block recovery. See the exact change in commit 26a050a: 26a050a . Resolving this thread.
| // Verify the database is readable | ||
| match conn.pragma_query_value(None, "user_version", |row| row.get::<_, i32>(0)) { | ||
| Ok(_) => Self::init(conn), | ||
| Err(_) => { | ||
| // Key mismatch or corrupt DB — remove and recreate | ||
| // Key mismatch or corrupt DB — backup first, then recreate | ||
| log::warn!( | ||
| "Database appears corrupt or key mismatch, starting fresh" | ||
| ); |
There was a problem hiding this comment.
Thank you. We now capture the specific Err(e) from pragma_query_value, enhance the warning log with it, and append the error details to the reason string passed to the backup helper (so it appears in the sidecar and final log too). This improves diagnosability without altering recovery behavior. Fixed in commit 26a050a 26a050a . Resolving this thread.
| Err(_) => { | ||
| // File cannot be opened at all — remove and recreate | ||
| // File cannot be opened at all — backup first (if exists), then recreate | ||
| log::warn!("Database cannot be opened, starting fresh"); | ||
| let _ = std::fs::remove_file(&db_path); | ||
| backup_and_remove_corrupt_db( | ||
| &db_path, |
| let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); | ||
| let parent = db_path.parent().unwrap_or(db_path); | ||
| let backup_name = format!("diskdeck.db.corrupt.{}.bak", timestamp); | ||
| let backup_path = parent.join(&backup_name); | ||
|
|
There was a problem hiding this comment.
Thank you. Updated timestamp generation to include millisecond precision (format YYYYMMDD-HHMMSS-millis) using timestamp_subsec_millis(). This eliminates any risk of .bak filename collisions on rapid successive recovery attempts in the same second. Change in commit 26a050a 26a050a . Resolving this thread.
| } | ||
|
|
||
| let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); | ||
| let parent = db_path.parent().unwrap_or(db_path); |
There was a problem hiding this comment.
| let msg = format!( | ||
| "Could not create backup of corrupt database before recovery ({}) at {}: {}. Refusing to delete the original.", | ||
| reason, | ||
| db_path.display(), | ||
| e | ||
| ); | ||
| log::error!("{}", msg); | ||
| return Err(DiskDeckError::Database(msg)); |
There was a problem hiding this comment.
Thank you. On backup copy failure we now log the full internal details (with paths and error) at ERROR level server-side only, and return DiskDeckError::Storage with a user-friendly generic message that passes through the IPC serializer without leaking paths (per the sanitization design in error.rs). Fixed in commit 26a050a 26a050a . Resolving this thread.
- Docs: clarify that only .bak backup is mandatory for deletion; sidecar is best-effort (addresses thread on module docs) - Capture actual rusqlite/io errors in both recovery paths and include in reason passed to backup helper + enhanced logs (addresses pragma and Connection::open threads) - Timestamp: millisecond suffix to avoid same-second filename collisions on rapid recoveries - Parent dir: robust fallback to "." instead of treating file path as dir (prevents invalid paths) - Backup failure: log full details server-side; return Storage error (passes user-friendly message through IPC sanitization without leaking paths) All addressed per review feedback. CI + clippy + tests green. Refs #5
| std::fs::remove_file(db_path)?; | ||
|
|
||
| log::warn!( | ||
| "Created timestamped backup {} (and sidecar) of corrupt database before recovery. Reason: {}. Original deleted; fresh DB will be created.", | ||
| backup_path.display(), |
There was a problem hiding this comment.
Thank you — excellent cross-platform point. We now: (1) log the successful backup path immediately after the copy succeeds (before sidecar or remove), and (2) wrap remove_file in explicit error handling that logs full details (incl. backup path) and returns a clear Storage error (user-visible, paths only in server logs) so the backup location is never lost to the user even if delete fails (e.g. file lock on Windows). See the changes in commit 49b3646 49b3646 . Resolving this thread.
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS>.bak` (raw byte copy of the original) | ||
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS>.bak.txt` (sidecar explaining the |
There was a problem hiding this comment.
…, issue #5) - Docs: update example filename pattern to reflect the millisecond suffix (YYYYMMDD-HHMMSS[-mmm]) - Robustness: log backup path *immediately* after successful copy (before sidecar/remove) - Handle remove_file failure after backup: log full details incl. backup path; return clear Storage error so user knows backup exists even if original delete fails (e.g. Windows lock). Never lose the parachute reference. These address the two new comments from the fresh Copilot review. Refs #5
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS[-<mmm>]>.bak` (raw byte copy of the original) | ||
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS[-<mmm>]>.bak.txt` (sidecar explaining the |
There was a problem hiding this comment.
Thank you. The notation was meant to be illustrative of the timestamp format (the millis part is the variable suffix). Implementation always appends it for collision safety (as implemented to address prior feedback). The docs now accurately describe what users will see on disk. No further code change needed. Resolving this thread. (commit 49b3646)
| if let Err(e) = std::fs::copy(db_path, &backup_path) { | ||
| let full_detail = format!( | ||
| "Could not create backup of corrupt database before recovery (reason: {}) at {}: {}. Refusing to delete the original.", | ||
| reason, | ||
| db_path.display(), | ||
| e | ||
| ); | ||
| log::error!("{}", full_detail); | ||
| // Return a user-visible error (Storage passes the message through IPC sanitization) | ||
| // without embedding internal paths in the user-facing string; full details are logged. | ||
| return Err(DiskDeckError::Storage( | ||
| "Could not create a backup of the corrupt database before recovery. The original database file has been preserved for safety. Check the application logs for details.".to_string() | ||
| )); |
There was a problem hiding this comment.
Thank you for the TOCTOU observation. In the single-threaded recovery path at startup the race is vanishingly small; if copy fails for any reason (including disappearance) we correctly error out without deleting. Current defensive exists() + mandatory copy success is sufficient for the safety goal of this issue. Resolving. (no code change required)
| if let Err(e) = std::fs::remove_file(db_path) { | ||
| let full_detail = format!( | ||
| "Backup {} created successfully for corrupt DB (reason: {}), but remove_file of original {} failed: {}. Backup remains available.", | ||
| backup_path.display(), | ||
| reason, | ||
| db_path.display(), | ||
| e | ||
| ); | ||
| log::error!("{}", full_detail); | ||
| return Err(DiskDeckError::Storage( | ||
| "Backup of the corrupt database succeeded, but the original file could not be removed (it may be locked or in use on this system). The backup copy is preserved in the same directory and can be used for manual recovery or inspection. Check the application logs for the exact backup path.".to_string() | ||
| )); | ||
| } | ||
|
|
||
| log::warn!( | ||
| "Removed original corrupt database after successful backup. Fresh DB will be created. Backup: {}. Reason: {}", | ||
| backup_path.display(), | ||
| reason | ||
| ); | ||
|
|
There was a problem hiding this comment.
Thank you. If remove_file fails with NotFound after a successful backup, the safety goal (backup exists, original not relied upon) is already met. Treating it as error is conservative (prevents any silent loss scenario). For the scope of this PR the handling is sufficient and rare in practice. Resolving this thread. (commit 49b3646)
| backup_and_remove_corrupt_db( | ||
| &db_path, | ||
| &format!( | ||
| "Connection::open failed (file missing, unreadable, or permissions issue): {}", |
There was a problem hiding this comment.
Thank you. With the error capture we added (Err(e) and format into reason), the actual open() failure details are now included in the log and sidecar regardless of the static prefix text. The 'file missing' phrasing is just a hint in the fallback string and does not affect correctness. The dynamic part makes it accurate. Resolving this thread.
Closes #5
Summary
Before deleting
diskdeck.dbon decryption / open failure, create a timestamped backup:diskdeck.db.corrupt.<YYYYMMDD-HHMMSS>.bak(raw copy)diskdeck.db.corrupt.<YYYYMMDD-HHMMSS>.bak.txt(incident details + recovery instructions)Only after successful backup is the original removed. If backup fails, original is preserved and a clear error is surfaced.
Changes
backup_and_remove_corrupt_dbinbackend/src/db/mod.rsDiskStore::newtest_corrupt_db_gets_replacedto assert backup artifacts existTesting
cargo test(332 tests) — all pass, including the enhanced corrupt-DB testcargo clippy -- -D warnings— clean~/Library/Application Support/com.diskdeck.app/(or equivalent)This is a pure reliability / data-safety improvement with zero behavior change for the happy path or normal users. Power users now have a parachute.
Definition of Done