diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index f800f21..b0f364d 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -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.]>.bak` (raw byte copy of the original) +//! - `diskdeck.db.corrupt.]>.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): {}", + 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() + )); + } + + // 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 + ); + + 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]