Skip to main content

rightkit_db/
recover.rs

1//! Open-time recovery: key rotation interrupted before the credential store was updated, and
2//! corrupt databases.
3//!
4//! - **Key candidates**: the caller lists keys in preference order (current first, then a
5//!   pending/previous key from a staged rotation). The first that decrypts wins and its index
6//!   is reported so the app can promote it in its credential store (MailRight `open_at`).
7//! - **Quarantine**: a database that is not a database (garbage, truncated header) is renamed
8//!   aside with its sidecars and a fresh file is created, instead of crash-looping on every
9//!   start. A *wrong key* is indistinguishable from corruption to SQLCipher, so a keyed
10//!   database is only ever quarantined when the policy says so explicitly.
11
12use crate::{DbError, DbOptions, Result};
13use rusqlite::{Connection, ErrorCode};
14use std::path::{Path, PathBuf};
15use zeroize::Zeroizing;
16
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
18pub struct RecoveryPolicy {
19    /// Quarantine-and-recreate an unreadable *plain* database.
20    pub quarantine_corrupt: bool,
21    /// Also allow it for a keyed database (destroys access to data a wrong key could have read).
22    pub quarantine_keyed: bool,
23}
24
25#[derive(Debug)]
26pub struct OpenOutcome {
27    pub conn: Connection,
28    /// Index into the candidate list that opened the database (0 for a plain open).
29    pub key_index: usize,
30    /// Where the unreadable database was moved, when recovery quarantined it.
31    pub quarantined: Option<PathBuf>,
32}
33
34impl DbOptions {
35    /// Try each passphrase in order; the first that decrypts opens the writer.
36    pub fn open_writer_with_key_candidates(
37        &self,
38        candidates: &[Zeroizing<String>],
39    ) -> Result<(Connection, usize)> {
40        let mut first_error = None;
41        for (index, candidate) in candidates.iter().enumerate() {
42            let mut attempt = self.clone();
43            attempt.key = Some(candidate.clone());
44            attempt.raw_key = None;
45            match attempt.open_writer() {
46                Ok(conn) => return Ok((conn, index)),
47                Err(error) => {
48                    first_error.get_or_insert(error);
49                }
50            }
51        }
52        Err(first_error.unwrap_or(DbError::NoKeyOpened))
53    }
54
55    /// Open the writer; if the file is unreadable and the policy allows it, quarantine it and
56    /// open a fresh database at the same path.
57    pub fn open_writer_recovering(&self, policy: RecoveryPolicy) -> Result<OpenOutcome> {
58        match self.open_writer() {
59            Ok(conn) => Ok(OpenOutcome {
60                conn,
61                key_index: 0,
62                quarantined: None,
63            }),
64            Err(error) if is_unreadable(&error) && allowed(self, policy) => {
65                let moved = quarantine_database(&self.path)?;
66                let conn = self.open_writer()?;
67                Ok(OpenOutcome {
68                    conn,
69                    key_index: 0,
70                    quarantined: Some(moved),
71                })
72            }
73            Err(error) => Err(error),
74        }
75    }
76}
77
78fn allowed(options: &DbOptions, policy: RecoveryPolicy) -> bool {
79    if options.is_keyed() {
80        policy.quarantine_keyed
81    } else {
82        policy.quarantine_corrupt
83    }
84}
85
86fn is_unreadable(error: &DbError) -> bool {
87    matches!(
88        error,
89        DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _))
90            if matches!(failure.code, ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt)
91    )
92}
93
94/// Move `db` and its `-wal` / `-shm` / `-journal` sidecars to `<db>.corrupt-<unix-ms>[...]`.
95/// Returns the new main-file path. Nothing is deleted.
96pub fn quarantine_database(db: &Path) -> Result<PathBuf> {
97    let name = db
98        .file_name()
99        .ok_or(DbError::InvalidDatabasePath)?
100        .to_string_lossy()
101        .into_owned();
102    let stamp = std::time::SystemTime::now()
103        .duration_since(std::time::UNIX_EPOCH)
104        .map(|d| d.as_millis())
105        .unwrap_or(0);
106    let dest = db.with_file_name(format!("{name}.corrupt-{stamp}"));
107    std::fs::rename(db, &dest).map_err(|e| DbError::Fs(rightkit_fs::FsError::io(db, e)))?;
108    for suffix in ["-wal", "-shm", "-journal"] {
109        let mut from = db.as_os_str().to_os_string();
110        from.push(suffix);
111        let mut to = dest.as_os_str().to_os_string();
112        to.push(suffix);
113        let _ = std::fs::rename(PathBuf::from(from), PathBuf::from(to));
114    }
115    Ok(dest)
116}
117
118/// Rotate the SQLCipher key in place (`PRAGMA rekey`, atomic inside SQLCipher) and verify the
119/// connection still reads. The caller stages the new key in its credential store *before* calling
120/// this and promotes it after; an interruption between is recovered with
121/// [`DbOptions::open_writer_with_key_candidates`].
122#[cfg(feature = "sqlcipher")]
123pub fn rekey(conn: &Connection, new_key: &str) -> Result<()> {
124    crate::validate_key(Some(new_key))?;
125    let statement = Zeroizing::new(format!("PRAGMA rekey = '{new_key}';"));
126    conn.execute_batch(statement.as_str())?;
127    crate::verify_key(conn)
128}