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 std::time::{SystemTime, UNIX_EPOCH};
16use zeroize::Zeroizing;
17
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub struct RecoveryPolicy {
20    /// Quarantine-and-recreate an unreadable *plain* database.
21    pub quarantine_corrupt: bool,
22    /// Also allow it for a keyed database (destroys access to data a wrong key could have read).
23    pub quarantine_keyed: bool,
24    /// Suffix format of the quarantined file. Default `.corrupt-<unix-ms>`.
25    pub quarantine_stamp: QuarantineStamp,
26    /// What happens to the `-wal`, `-shm` and `-journal` sidecars. Default renames them alongside
27    /// the quarantined file.
28    pub sidecars: SidecarPolicy,
29}
30
31/// Suffix format of a quarantined database, appended to its file name.
32#[derive(Clone, Copy, Debug, Default)]
33pub enum QuarantineStamp {
34    /// `.corrupt-<unix-ms>` (default).
35    #[default]
36    Millis,
37    /// `.corrupt-<unix-secs>`.
38    Seconds,
39    /// The formatter returns the whole suffix, for example `.bad-<n>`. The result must be
40    /// non-empty and contain no path separator or NUL, or quarantine fails with
41    /// [`DbError::InvalidQuarantineSuffix`].
42    Custom(fn(SystemTime) -> String),
43}
44
45// Function pointers are compared by address with `fn_addr_eq`, which is the sanctioned form:
46// a derived `==` on `fn` pointers triggers `unpredictable_function_pointer_comparisons`.
47// Equal addresses compare equal, so `Eq` holds.
48impl PartialEq for QuarantineStamp {
49    fn eq(&self, other: &Self) -> bool {
50        match (self, other) {
51            (Self::Millis, Self::Millis) | (Self::Seconds, Self::Seconds) => true,
52            (Self::Custom(a), Self::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
53            _ => false,
54        }
55    }
56}
57
58impl Eq for QuarantineStamp {}
59
60impl QuarantineStamp {
61    fn suffix(self, now: SystemTime) -> String {
62        let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
63        match self {
64            Self::Millis => format!(".corrupt-{}", since_epoch.as_millis()),
65            Self::Seconds => format!(".corrupt-{}", since_epoch.as_secs()),
66            Self::Custom(format) => format(now),
67        }
68    }
69}
70
71/// What happens to the `-wal`, `-shm` and `-journal` sidecars of a quarantined database.
72#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
73pub enum SidecarPolicy {
74    /// Move each sidecar beside the quarantined file, under the same suffix. Nothing is deleted
75    /// (default).
76    #[default]
77    RenameSidecars,
78    /// Delete the sidecars. Their bytes are not kept for diagnosis, and no stale WAL is left at
79    /// the original path.
80    DeleteSidecars,
81}
82
83#[derive(Debug)]
84pub struct OpenOutcome {
85    pub conn: Connection,
86    /// Index into the candidate list that opened the database (0 for a plain open).
87    pub key_index: usize,
88    /// Where the unreadable database was moved, when recovery quarantined it.
89    pub quarantined: Option<PathBuf>,
90}
91
92impl DbOptions {
93    /// Try each passphrase in order; the first that decrypts opens the writer.
94    pub fn open_writer_with_key_candidates(
95        &self,
96        candidates: &[Zeroizing<String>],
97    ) -> Result<(Connection, usize)> {
98        let mut first_error = None;
99        for (index, candidate) in candidates.iter().enumerate() {
100            let mut attempt = self.clone();
101            attempt.key = Some(candidate.clone());
102            attempt.raw_key = None;
103            match attempt.open_writer() {
104                Ok(conn) => return Ok((conn, index)),
105                Err(error) => {
106                    first_error.get_or_insert(error);
107                }
108            }
109        }
110        Err(first_error.unwrap_or(DbError::NoKeyOpened))
111    }
112
113    /// Open the writer; if the file is unreadable and the policy allows it, quarantine it and
114    /// open a fresh database at the same path.
115    pub fn open_writer_recovering(&self, policy: RecoveryPolicy) -> Result<OpenOutcome> {
116        match self.open_writer() {
117            Ok(conn) => Ok(OpenOutcome {
118                conn,
119                key_index: 0,
120                quarantined: None,
121            }),
122            Err(error) if is_unreadable(&error) && allowed(self, policy) => {
123                let moved =
124                    quarantine_database_with(&self.path, policy.quarantine_stamp, policy.sidecars)?;
125                let conn = self.open_writer()?;
126                Ok(OpenOutcome {
127                    conn,
128                    key_index: 0,
129                    quarantined: Some(moved),
130                })
131            }
132            Err(error) => Err(error),
133        }
134    }
135}
136
137pub(crate) fn allowed(options: &DbOptions, policy: RecoveryPolicy) -> bool {
138    if options.is_keyed() {
139        policy.quarantine_keyed
140    } else {
141        policy.quarantine_corrupt
142    }
143}
144
145/// True when the file is not a usable SQLite database: SQLite reports it as not-a-database or
146/// corrupt, or an open-time integrity check failed ([`DbError::Integrity`], from `Quick` or `Full`).
147/// Busy, locked, permission and other I/O failures are not unreadable. They are transient or
148/// environmental, and quarantining on them would move a healthy file aside.
149pub(crate) fn is_unreadable(error: &DbError) -> bool {
150    match error {
151        DbError::Integrity(_) => true,
152        DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _)) => matches!(
153            failure.code,
154            ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt
155        ),
156        _ => false,
157    }
158}
159
160/// Move `db` and its `-wal` / `-shm` / `-journal` sidecars to `<db>.corrupt-<unix-ms>[...]`.
161/// Returns the new main-file path. Nothing is deleted.
162pub fn quarantine_database(db: &Path) -> Result<PathBuf> {
163    quarantine_database_with(db, QuarantineStamp::Millis, SidecarPolicy::RenameSidecars)
164}
165
166/// [`quarantine_database`] with an explicit suffix format and sidecar policy. Refuses to overwrite
167/// an existing file at the destination. Sidecar failures are errors, not ignored: a `-wal` left
168/// at the original path would be replayed into the replacement database.
169pub fn quarantine_database_with(
170    db: &Path,
171    stamp: QuarantineStamp,
172    sidecars: SidecarPolicy,
173) -> Result<PathBuf> {
174    quarantine_database_opts(
175        db,
176        &QuarantineOptions {
177            name: stamp,
178            sidecars,
179            ..QuarantineOptions::default()
180        },
181    )
182}
183
184/// How a quarantined file leaves its original path.
185#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
186pub enum MoveMode {
187    /// `rename(2)`: one step, within the directory (default).
188    #[default]
189    Rename,
190    /// Hard-link the file to its destination, then remove the original, so the destination
191    /// exists before the original name goes away. Fails, with the original still in place, where
192    /// hard links are not supported.
193    HardLinkThenRemove,
194}
195
196/// Options for [`quarantine_database_opts`]. `Default` matches [`quarantine_database`].
197#[derive(Clone, Debug, Default)]
198pub struct QuarantineOptions {
199    /// Suffix format appended to the database file name.
200    pub name: QuarantineStamp,
201    /// What happens to the `-wal`, `-shm` and `-journal` sidecars.
202    pub sidecars: SidecarPolicy,
203    /// How the main file and the sidecars leave their original names.
204    pub move_mode: MoveMode,
205    /// Recovery marker written atomically (owner-only) before any file moves. It is JSON:
206    /// `{"schema", "original", "quarantined", "unixMs"}`. It stays in place after success; the app
207    /// removes it when recovery is complete.
208    pub marker: Option<PathBuf>,
209}
210
211/// Attempts for a unique quarantine name under [`QuarantineStamp::Seconds`]: the base name, then
212/// `-1`, `-2`, ... up to `-(QUARANTINE_NAME_ATTEMPTS - 1)`.
213const QUARANTINE_NAME_ATTEMPTS: u32 = 100;
214
215/// [`quarantine_database`] with full [`QuarantineOptions`]. The same refusal rules apply to the
216/// other stamps: an existing destination is an error, and sidecar failures are errors.
217///
218/// [`QuarantineStamp::Seconds`] names are unique per second, so a second quarantine within the same
219/// second takes `<db>.corrupt-<secs>-1`, `-2`, and so on. A name is claimed with create-new
220/// semantics (`rename_exclusive`, or `link` for [`MoveMode::HardLinkThenRemove`]), never by
221/// check-then-rename, so a concurrent quarantine cannot be overwritten.
222pub fn quarantine_database_opts(db: &Path, options: &QuarantineOptions) -> Result<PathBuf> {
223    let name = db
224        .file_name()
225        .ok_or(DbError::InvalidDatabasePath)?
226        .to_string_lossy()
227        .into_owned();
228    let suffix = options.name.suffix(SystemTime::now());
229    if suffix.is_empty() || suffix.contains(['/', '\\', '\0']) {
230        return Err(DbError::InvalidQuarantineSuffix(suffix));
231    }
232    let attempts = match options.name {
233        QuarantineStamp::Seconds => QUARANTINE_NAME_ATTEMPTS,
234        _ => 1,
235    };
236    let mut last = None;
237    for attempt in 0..attempts {
238        let dest = quarantine_candidate(db, &name, &suffix, attempt);
239        if quarantine_name_taken(&dest, options.sidecars) {
240            last = Some(dest);
241            continue;
242        }
243        if let Some(marker) = &options.marker {
244            write_marker(marker, db, &dest)?;
245        }
246        match move_file(db, &dest, options.move_mode) {
247            Ok(()) => {}
248            // Another quarantine claimed the name after the check: try the next one.
249            Err(error)
250                if error.kind() == std::io::ErrorKind::AlreadyExists && attempt + 1 < attempts =>
251            {
252                last = Some(dest);
253                continue;
254            }
255            Err(error) => return Err(DbError::Fs(rightkit_fs::FsError::io(db, error))),
256        }
257        for sidecar in ["-wal", "-shm", "-journal"] {
258            let from = with_suffix(db, sidecar);
259            match options.sidecars {
260                SidecarPolicy::RenameSidecars => {
261                    move_if_present(&from, &with_suffix(&dest, sidecar), options.move_mode)?
262                }
263                SidecarPolicy::DeleteSidecars => remove_if_present(&from)?,
264            }
265        }
266        return Ok(dest);
267    }
268    let dest = last.unwrap_or_else(|| quarantine_candidate(db, &name, &suffix, 0));
269    Err(DbError::Fs(rightkit_fs::FsError::io(
270        &dest,
271        std::io::Error::new(
272            std::io::ErrorKind::AlreadyExists,
273            "quarantine destination already exists",
274        ),
275    )))
276}
277
278/// The `attempt`-th destination for a quarantined `db`: `<db><suffix>`, then `<db><suffix>-1`, ...
279fn quarantine_candidate(db: &Path, name: &str, suffix: &str, attempt: u32) -> PathBuf {
280    if attempt == 0 {
281        db.with_file_name(format!("{name}{suffix}"))
282    } else {
283        db.with_file_name(format!("{name}{suffix}-{attempt}"))
284    }
285}
286
287/// True when the destination, or (under [`SidecarPolicy::RenameSidecars`]) one of its sidecar
288/// names, already exists. A dangling link counts as existing.
289fn quarantine_name_taken(dest: &Path, sidecars: SidecarPolicy) -> bool {
290    fn exists(path: &Path) -> bool {
291        std::fs::symlink_metadata(path).is_ok()
292    }
293    exists(dest)
294        || (sidecars == SidecarPolicy::RenameSidecars
295            && ["-wal", "-shm", "-journal"]
296                .iter()
297                .any(|sidecar| exists(&with_suffix(dest, sidecar))))
298}
299
300/// Moves `from` to `to`, failing with `AlreadyExists` (and leaving both names) when `to` exists.
301/// Both modes claim `to` atomically: `rename_exclusive` for `Rename`, `link` for `HardLinkThenRemove`.
302fn move_file(from: &Path, to: &Path, mode: MoveMode) -> std::io::Result<()> {
303    match mode {
304        MoveMode::Rename => rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from),
305        MoveMode::HardLinkThenRemove => {
306            std::fs::hard_link(from, to)?;
307            std::fs::remove_file(from)
308        }
309    }
310}
311
312fn move_if_present(from: &Path, to: &Path, mode: MoveMode) -> Result<()> {
313    match move_file(from, to, mode) {
314        Ok(()) => Ok(()),
315        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
316        Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(from, error))),
317    }
318}
319
320fn write_marker(marker: &Path, original: &Path, quarantined: &Path) -> Result<()> {
321    let unix_ms = SystemTime::now()
322        .duration_since(UNIX_EPOCH)
323        .unwrap_or_default()
324        .as_millis();
325    let body = format!(
326        "{{\"schema\":\"rightkit-db-quarantine.v1\",\"original\":{},\"quarantined\":{},\"unixMs\":{}}}\n",
327        json_string(&original.to_string_lossy()),
328        json_string(&quarantined.to_string_lossy()),
329        unix_ms
330    );
331    rightkit_fs::atomic_write_private(marker, body.as_bytes())?;
332    Ok(())
333}
334
335/// A JSON string literal for `value`, escaping quotes, backslashes and control characters.
336fn json_string(value: &str) -> String {
337    let mut out = String::with_capacity(value.len() + 2);
338    out.push('"');
339    for c in value.chars() {
340        match c {
341            '"' => out.push_str("\\\""),
342            '\\' => out.push_str("\\\\"),
343            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
344            c => out.push(c),
345        }
346    }
347    out.push('"');
348    out
349}
350
351fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
352    let mut name = path.as_os_str().to_os_string();
353    name.push(suffix);
354    PathBuf::from(name)
355}
356
357fn remove_if_present(path: &Path) -> Result<()> {
358    match std::fs::remove_file(path) {
359        Ok(()) => Ok(()),
360        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
361        Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(path, error))),
362    }
363}
364
365/// Rotate the SQLCipher key in place (`PRAGMA rekey`, atomic inside SQLCipher) and verify the
366/// connection still reads. The caller stages the new key in its credential store *before* calling
367/// this and promotes it after; an interruption between is recovered with
368/// [`DbOptions::open_writer_with_key_candidates`].
369#[cfg(feature = "cipher")]
370pub fn rekey(conn: &Connection, new_key: &str) -> Result<()> {
371    crate::validate_key(Some(new_key))?;
372    let statement = Zeroizing::new(format!("PRAGMA rekey = '{new_key}';"));
373    conn.execute_batch(statement.as_str())?;
374    crate::verify_key(conn)
375}