Skip to content

Make database recovery on decryption failure safer by backing up the corrupt DB first #5

Description

@koraytaylan

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

  1. Keychain can be flaky — especially during development or after OS updates, corporate MDM policies, or when the user denies keychain access the first time.
  2. The welcome dialog + retry loop exists precisely because keychain access is fragile.
  3. A user who has 15 carefully configured remote disks loses everything the moment the DB fails to decrypt once.
  4. There is no way for even an advanced user to recover — the file is gone before any error message reaches them.
  5. 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:

  1. 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)

  2. 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
  3. Only after the backup succeeds, delete the original diskdeck.db.

  4. Log (at WARN level) the full path of the backup that was created.

  5. 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

  1. Read the full new function in db/mod.rs (lines 156–195). Understand the three recovery branches.

  2. Extract a small helper function (recommended):

    fn backup_and_remove_corrupt_db(db_path: &Path) -> Result<(), DiskDeckError> {
        // your logic here
    }
  3. Implement the timestamped backup logic

    Use chrono (already a dependency) or std::time + formatting to create the filename.

  4. Call the helper from all three places where we currently delete the file.

  5. 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."

  6. 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
  7. Run the full test suite and clippy.

Acceptance Criteria

  • Before any remove_file of diskdeck.db, a timestamped backup copy is created with a .bak suffix
  • A small .txt sidecar explaining the incident is also written (nice-to-have but strongly encouraged)
  • If creating the backup fails for any reason, the original DB file is not deleted and a clear error is returned
  • All existing DB tests continue to pass
  • cargo clippy -- -D warnings is clean
  • A new test exercises the backup path (you can use tempfile::tempdir() + real DiskStore::new)
  • The log message at WARN level now includes the path of the backup that was created

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions