-
Notifications
You must be signed in to change notification settings - Fork 0
feat: safer database recovery — backup corrupt diskdeck.db before delete #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
405f732
6a32eef
26a050a
49b3646
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,8 +15,23 @@ | |
| //! ## Recovery from corrupt or unreadable databases | ||
| //! | ||
| //! If the database file exists but cannot be decrypted (wrong key or | ||
| //! corruption), it is deleted and recreated from scratch. Disk configs must be | ||
| //! re-added by the user, and bookmarks and preferences are non-critical. | ||
| //! corruption detected via failed `user_version` pragma or `Connection::open`), | ||
| //! a timestamped backup is created first: | ||
| //! | ||
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS[-<mmm>]>.bak` (raw byte copy of the original) | ||
| //! - `diskdeck.db.corrupt.<YYYYMMDD-HHMMSS[-<mmm>]>.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 copy (`.bak` file) succeeds is the corrupt `diskdeck.db` removed | ||
| //! and a fresh database created. The accompanying `.bak.txt` sidecar is | ||
| //! best-effort (written after the critical backup copy; a sidecar failure does | ||
| //! not prevent deletion of the original or recovery). 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. | ||
| //! | ||
| //! ## Schema migrations | ||
| //! | ||
|
|
@@ -166,13 +181,20 @@ impl DiskStore { | |
| // 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 | ||
| Err(e) => { | ||
| // Key mismatch or corrupt DB — backup first, then recreate | ||
| log::warn!( | ||
| "Database appears corrupt or key mismatch, starting fresh" | ||
| "Database appears corrupt or key mismatch, starting fresh: {}", | ||
| e | ||
| ); | ||
| drop(conn); | ||
| let _ = std::fs::remove_file(&db_path); | ||
| backup_and_remove_corrupt_db( | ||
| &db_path, | ||
| &format!( | ||
| "user_version pragma failed after key application (possible key mismatch or corruption): {}", | ||
| e | ||
| ), | ||
| )?; | ||
| let conn = Connection::open(&db_path)?; | ||
| if !key.is_empty() { | ||
| conn.pragma_update(None, "key", format!("x'{key}'"))?; | ||
|
|
@@ -181,10 +203,16 @@ impl DiskStore { | |
| } | ||
| } | ||
| } | ||
| Err(_) => { | ||
| // File cannot be opened at all — remove and recreate | ||
| log::warn!("Database cannot be opened, starting fresh"); | ||
| let _ = std::fs::remove_file(&db_path); | ||
| Err(e) => { | ||
| // File cannot be opened at all — backup first (if exists), then recreate | ||
| log::warn!("Database cannot be opened, starting fresh: {}", e); | ||
| backup_and_remove_corrupt_db( | ||
| &db_path, | ||
| &format!( | ||
| "Connection::open failed (file missing, unreadable, or permissions issue): {}", | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| e | ||
| ), | ||
| )?; | ||
| let conn = Connection::open(&db_path)?; | ||
| if !key.is_empty() { | ||
| conn.pragma_update(None, "key", format!("x'{key}'"))?; | ||
|
|
@@ -844,6 +872,117 @@ fn run_migrations(conn: &Connection) -> Result<(), DiskDeckError> { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Backs up a corrupt/unreadable `diskdeck.db` (if it exists) to a timestamped | ||
| /// `.bak` file + explanatory `.txt` sidecar in the same directory, then removes | ||
| /// the original. If the backup step fails, the original is left in place and | ||
| /// an error is returned so the caller does not blindly delete user data. | ||
| /// | ||
| /// This is the single place that implements the "never delete without a parachute" | ||
| /// safety rule for database recovery. Called from the two recovery branches in | ||
| /// [`DiskStore::new`]. | ||
| /// | ||
| /// The sidecar is best-effort (does not fail the backup if it cannot be written). | ||
| fn backup_and_remove_corrupt_db(db_path: &Path, reason: &str) -> Result<(), DiskDeckError> { | ||
| if !db_path.exists() { | ||
| // No file to back up (e.g. first-run or already-deleted). Proceed to create fresh. | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let now = chrono::Local::now(); | ||
| // Use millisecond precision to avoid filename collisions on rapid successive recoveries. | ||
| let timestamp = format!( | ||
| "{}-{:03}", | ||
| now.format("%Y%m%d-%H%M%S"), | ||
| now.timestamp_subsec_millis() | ||
| ); | ||
| let parent = db_path | ||
| .parent() | ||
| .filter(|p| !p.as_os_str().is_empty()) | ||
| .unwrap_or_else(|| std::path::Path::new(".")); | ||
| let backup_name = format!("diskdeck.db.corrupt.{}.bak", timestamp); | ||
| let backup_path = parent.join(&backup_name); | ||
|
|
||
| // 1. Copy the raw bytes first. This must succeed before we consider deleting. | ||
| 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() | ||
| )); | ||
|
Comment on lines
+906
to
+918
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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) |
||
| } | ||
|
|
||
| // Log backup success *immediately* after copy (before any later steps that could fail). | ||
| // This ensures the backup location is always recorded in logs even if sidecar or remove_file later fail. | ||
| log::warn!( | ||
| "Successfully created timestamped backup {} of corrupt database before recovery. Reason: {}. Original will now be removed.", | ||
| backup_path.display(), | ||
| reason | ||
| ); | ||
|
|
||
| // 2. Best-effort sidecar (optional but strongly recommended per requirements). | ||
| let sidecar_name = format!("diskdeck.db.corrupt.{}.bak.txt", timestamp); | ||
| let sidecar_path = parent.join(&sidecar_name); | ||
| let sidecar_content = format!( | ||
| "DiskDeck database recovery backup\n\ | ||
| \n\ | ||
| Reason: {}\n\ | ||
| Timestamp (local): {}\n\ | ||
| Original database path: {}\n\ | ||
| Backup file: {}\n\ | ||
| \n\ | ||
| This is a raw byte-for-byte copy of the diskdeck.db file that could not be\n\ | ||
| opened/decrypted (key mismatch, keychain issue, or file corruption).\n\ | ||
| \n\ | ||
| To attempt manual recovery with a different key or SQLCipher tooling:\n\ | ||
| 1. Rename the .bak file back to 'diskdeck.db' in the same directory.\n\ | ||
| 2. Ensure the correct 32-byte encryption key is available in the OS keychain.\n\ | ||
| 3. Restart DiskDeck.\n\ | ||
| \n\ | ||
| Power users and support staff can use this file + sidecar for post-mortem\n\ | ||
| analysis or forensic recovery. The backup is never encrypted or moved.\n", | ||
| reason, timestamp, db_path.display(), backup_path.display() | ||
| ); | ||
| if let Err(e) = std::fs::write(&sidecar_path, sidecar_content) { | ||
| log::warn!( | ||
| "Backup .bak created successfully but sidecar {} could not be written: {}", | ||
| sidecar_path.display(), | ||
| e | ||
| ); | ||
| // Do not fail the whole backup for sidecar — it is user-friendly, not mandatory for safety. | ||
| } | ||
|
|
||
| // 3. Only now is it safe to delete the original. | ||
| // Handle remove failure gracefully: the backup exists and is the important artifact for recovery. | ||
| 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 | ||
| ); | ||
|
|
||
|
Comment on lines
+963
to
+982
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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) |
||
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
@@ -1003,8 +1142,8 @@ mod tests { | |
| // Write garbage to simulate an encrypted/corrupt database file | ||
| std::fs::write(&db_path, b"not a sqlite database at all").unwrap(); | ||
|
|
||
| // DiskStore::new should detect the corruption, delete the file, | ||
| // and create a fresh database | ||
| // DiskStore::new should detect the corruption, *backup* the file first | ||
| // (creating .bak + .txt sidecar), delete the original, and create fresh DB. | ||
| let store = DiskStore::new(dir.path(), "").unwrap(); | ||
| let disks = store.load().unwrap(); | ||
| assert!(disks.is_empty()); | ||
|
|
@@ -1015,6 +1154,29 @@ mod tests { | |
| .unwrap(); | ||
| let loaded = store.load().unwrap(); | ||
| assert_eq!(loaded.len(), 1); | ||
|
|
||
| // Critical safety check: a timestamped backup + sidecar must exist next to the (now-deleted) original | ||
| let dir_entries: Vec<_> = std::fs::read_dir(dir.path()) | ||
| .unwrap() | ||
| .filter_map(Result::ok) | ||
| .collect(); | ||
| let bak_file = dir_entries.iter().find(|e| { | ||
| let name = e.file_name().to_string_lossy().to_string(); | ||
| name.contains("diskdeck.db.corrupt.") && name.ends_with(".bak") | ||
| }); | ||
| assert!( | ||
| bak_file.is_some(), | ||
| "expected a timestamped .bak backup file to have been created before deleting the corrupt DB" | ||
| ); | ||
|
|
||
| let txt_file = dir_entries.iter().find(|e| { | ||
| let name = e.file_name().to_string_lossy().to_string(); | ||
| name.contains("diskdeck.db.corrupt.") && name.ends_with(".bak.txt") | ||
| }); | ||
| assert!( | ||
| txt_file.is_some(), | ||
| "expected a .bak.txt sidecar explaining the recovery to have been created" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)