Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 174 additions & 12 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +21 to +22

Copy link
Copy Markdown
Owner Author

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)

//! 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
//!
Expand Down Expand Up @@ -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}'"))?;
Expand All @@ -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): {}",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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}'"))?;
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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::*;
Expand Down Expand Up @@ -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());
Expand All @@ -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]
Expand Down
Loading