Make Database Recovery on Decryption Failure Safer by Backing Up the Corrupt File First
Summary
When the SQLCipher-encrypted database (diskdeck.db) cannot be opened with the current key (wrong key, keychain corruption, or file corruption), the application immediately deletes the database file and creates a fresh one.
See:
// backend/src/db/mod.rs:175
let _ = std::fs::remove_file(&db_path);
This means all user data is permanently lost with no recovery path:
- Every registered disk (including S3/GCS/Azure/SFTP credentials)
- All bookmarks
- All preferences
- The entire FTS5 search index
While the current behavior is "secure by default" (better to start fresh than risk using a tampered DB), it is unnecessarily destructive from a user-experience and data-safety perspective.
Why the current behavior is risky
- Keychain can be flaky — especially during development or after OS updates, corporate MDM policies, or when the user denies keychain access the first time.
- The welcome dialog + retry loop exists precisely because keychain access is fragile.
- A user who has 15 carefully configured remote disks loses everything the moment the DB fails to decrypt once.
- There is no way for even an advanced user to recover — the file is gone before any error message reaches them.
- This contradicts the "your data stays on your machine" value proposition we advertise in the welcome dialog.
What we should do instead
Before we ever call remove_file, we should make a timestamped backup copy of the problematic diskdeck.db file.
Then we can safely delete the original and start fresh, while leaving the user (or support) a way to inspect or attempt manual recovery later.
Detailed Requirements (written for a junior developer)
1. Change location
All the recovery paths live in backend/src/db/mod.rs, inside the DiskStore::new function (the three places that currently do remove_file).
2. New behavior
When we detect that we need to start fresh (either the user_version pragma fails or the initial Connection::open fails), do the following in order:
-
If the diskdeck.db file exists at this moment, copy it to a new file named:
diskdeck.db.corrupt.<YYYYMMDD-HHMMSS>.bak
(Example: diskdeck.db.corrupt.20250519-141233.bak)
-
Write a small plain-text sidecar file next to it (optional but very user-friendly):
diskdeck.db.corrupt.20250519-141233.bak.txt
containing:
- The reason we considered it corrupt
- The time of the incident
- A one-sentence explanation that this file can be renamed back to
diskdeck.db if the user wants to attempt recovery with a different key or tool
-
Only after the backup succeeds, delete the original diskdeck.db.
-
Log (at WARN level) the full path of the backup that was created.
-
Continue with the normal "create fresh database" flow.
3. Important safety rules
- The backup must be created in the same directory as the original DB (so it is easy for the user to find).
- If the backup copy itself fails (disk full, permissions), we should abort the recovery and surface a clear error to the user instead of blindly deleting the original. Losing the DB is bad; losing it while also failing to back it up would be catastrophic.
- Use
std::fs::copy (synchronous is fine here — this is startup).
- Do not attempt to interpret or decrypt the backup. We just copy the raw bytes.
4. User-visible behavior
After recovery, the user will simply see a fresh app (as today). They will not get a scary dialog about data loss.
Power users or support staff can look in ~/Library/Application Support/com.diskdeck.app/ (or equivalent on Windows/Linux) and will find the .bak file with a helpful .txt explanation.
We should also improve the log message that is already emitted.
Step-by-Step Implementation Guide
-
Read the full new function in db/mod.rs (lines 156–195). Understand the three recovery branches.
-
Extract a small helper function (recommended):
fn backup_and_remove_corrupt_db(db_path: &Path) -> Result<(), DiskDeckError> {
// your logic here
}
-
Implement the timestamped backup logic
Use chrono (already a dependency) or std::time + formatting to create the filename.
-
Call the helper from all three places where we currently delete the file.
-
Handle the case where the backup fails
Return a new or existing DiskDeckError variant that tells the user "Could not create backup of database before recovery — refusing to delete."
-
Write or update tests
The DB module already has many #[cfg(test)] tests using new_in_memory. You should add at least one test that:
- Creates a real on-disk DB file
- Corrupts it (or uses a wrong key)
- Calls the recovery path
- Asserts that a
.bak file now exists next to it
-
Run the full test suite and clippy.
Acceptance Criteria
Learning Goals
- Defensive programming and "never delete user data without a parachute"
- Working with the filesystem safely (
std::fs::copy, error handling on I/O)
- Thinking about supportability and post-mortem debugging (the
.bak + .txt files)
- How encryption + key management creates special data-loss scenarios
- Writing tests that involve real files on disk (more realistic than pure in-memory tests)
Files
backend/src/db/mod.rs (the only file you need to change for the fix)
- Possibly a small addition in
backend/src/error.rs if you want a dedicated error variant (optional)
Out of Scope
- Automatically offering the user to "restore from backup" in the UI (great future feature, but much larger)
- Encrypting or moving the backup to a different location
- Pruning old backup files after N days
Why this issue is great for a junior dev:
- The scope is small and contained (one function, one file).
- It has a very clear "before vs after" safety improvement.
- It directly protects real user data (disk credentials are extremely painful to lose).
- It teaches an important real-world mindset: "What happens when the happy path fails?"
A developer who completes this issue will have measurably increased the trustworthiness of the application.
Make Database Recovery on Decryption Failure Safer by Backing Up the Corrupt File First
Summary
When the SQLCipher-encrypted database (
diskdeck.db) cannot be opened with the current key (wrong key, keychain corruption, or file corruption), the application immediately deletes the database file and creates a fresh one.See:
This means all user data is permanently lost with no recovery path:
While the current behavior is "secure by default" (better to start fresh than risk using a tampered DB), it is unnecessarily destructive from a user-experience and data-safety perspective.
Why the current behavior is risky
What we should do instead
Before we ever call
remove_file, we should make a timestamped backup copy of the problematicdiskdeck.dbfile.Then we can safely delete the original and start fresh, while leaving the user (or support) a way to inspect or attempt manual recovery later.
Detailed Requirements (written for a junior developer)
1. Change location
All the recovery paths live in
backend/src/db/mod.rs, inside theDiskStore::newfunction (the three places that currently doremove_file).2. New behavior
When we detect that we need to start fresh (either the
user_versionpragma fails or the initialConnection::openfails), do the following in order:If the
diskdeck.dbfile exists at this moment, copy it to a new file named:(Example:
diskdeck.db.corrupt.20250519-141233.bak)Write a small plain-text sidecar file next to it (optional but very user-friendly):
containing:
diskdeck.dbif the user wants to attempt recovery with a different key or toolOnly after the backup succeeds, delete the original
diskdeck.db.Log (at WARN level) the full path of the backup that was created.
Continue with the normal "create fresh database" flow.
3. Important safety rules
std::fs::copy(synchronous is fine here — this is startup).4. User-visible behavior
After recovery, the user will simply see a fresh app (as today). They will not get a scary dialog about data loss.
Power users or support staff can look in
~/Library/Application Support/com.diskdeck.app/(or equivalent on Windows/Linux) and will find the.bakfile with a helpful.txtexplanation.We should also improve the log message that is already emitted.
Step-by-Step Implementation Guide
Read the full
newfunction indb/mod.rs(lines 156–195). Understand the three recovery branches.Extract a small helper function (recommended):
Implement the timestamped backup logic
Use
chrono(already a dependency) orstd::time+ formatting to create the filename.Call the helper from all three places where we currently delete the file.
Handle the case where the backup fails
Return a new or existing
DiskDeckErrorvariant that tells the user "Could not create backup of database before recovery — refusing to delete."Write or update tests
The DB module already has many
#[cfg(test)]tests usingnew_in_memory. You should add at least one test that:.bakfile now exists next to itRun the full test suite and clippy.
Acceptance Criteria
remove_fileofdiskdeck.db, a timestamped backup copy is created with a.baksuffix.txtsidecar explaining the incident is also written (nice-to-have but strongly encouraged)cargo clippy -- -D warningsis cleantempfile::tempdir()+ realDiskStore::new)Learning Goals
std::fs::copy, error handling on I/O).bak+.txtfiles)Files
backend/src/db/mod.rs(the only file you need to change for the fix)backend/src/error.rsif you want a dedicated error variant (optional)Out of Scope
Why this issue is great for a junior dev:
A developer who completes this issue will have measurably increased the trustworthiness of the application.