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    /// Explicit acknowledgement that recovery may fall back to a private in-memory store
30    /// when the stored database cannot be used. Default `false` (refuse): the open fails
31    /// with the original error instead of silently running on data that will not persist.
32    /// When `true`, the result still reports `degraded.in_memory`, and the configured
33    /// SQLCipher key is still applied to the in-memory store; it is never a plaintext
34    /// fallback and makes no persistence claim.
35    pub allow_in_memory_fallback: bool,
36}
37
38/// Suffix format of a quarantined database, appended to its file name.
39#[derive(Clone, Copy, Debug, Default)]
40pub enum QuarantineStamp {
41    /// `.corrupt-<unix-ms>` (default).
42    #[default]
43    Millis,
44    /// `.corrupt-<unix-secs>`.
45    Seconds,
46    /// The formatter returns the whole suffix, for example `.bad-<n>`. The result must be
47    /// non-empty and contain no path separator or NUL, or quarantine fails with
48    /// [`DbError::InvalidQuarantineSuffix`]. A name already taken gets `-1`, `-2`, ... appended,
49    /// as for [`QuarantineStamp::Seconds`], so an earlier quarantine is never overwritten.
50    Custom(fn(SystemTime) -> String),
51}
52
53// Function pointers are compared by address with `fn_addr_eq`, which is the sanctioned form:
54// a derived `==` on `fn` pointers triggers `unpredictable_function_pointer_comparisons`.
55// Equal addresses compare equal, so `Eq` holds.
56impl PartialEq for QuarantineStamp {
57    fn eq(&self, other: &Self) -> bool {
58        match (self, other) {
59            (Self::Millis, Self::Millis) | (Self::Seconds, Self::Seconds) => true,
60            (Self::Custom(a), Self::Custom(b)) => std::ptr::fn_addr_eq(*a, *b),
61            _ => false,
62        }
63    }
64}
65
66impl Eq for QuarantineStamp {}
67
68impl QuarantineStamp {
69    fn suffix(self, now: SystemTime) -> String {
70        let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default();
71        match self {
72            Self::Millis => format!(".corrupt-{}", since_epoch.as_millis()),
73            Self::Seconds => format!(".corrupt-{}", since_epoch.as_secs()),
74            Self::Custom(format) => format(now),
75        }
76    }
77}
78
79/// What happens to the `-wal`, `-shm` and `-journal` sidecars of a quarantined database.
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
81pub enum SidecarPolicy {
82    /// Move each sidecar beside the quarantined file, under the same suffix. Nothing is deleted
83    /// (default).
84    #[default]
85    RenameSidecars,
86    /// Delete the sidecars. Their bytes are not kept for diagnosis, and no stale WAL is left at
87    /// the original path.
88    DeleteSidecars,
89}
90
91#[derive(Debug)]
92pub struct OpenOutcome {
93    pub conn: Connection,
94    /// Index into the candidate list that opened the database (0 for a plain open).
95    pub key_index: usize,
96    /// Where the unreadable database was moved, when recovery quarantined it.
97    pub quarantined: Option<PathBuf>,
98}
99
100impl DbOptions {
101    /// Try each passphrase in order; the first that decrypts opens the writer.
102    pub fn open_writer_with_key_candidates(
103        &self,
104        candidates: &[Zeroizing<String>],
105    ) -> Result<(Connection, usize)> {
106        let mut first_error = None;
107        for (index, candidate) in candidates.iter().enumerate() {
108            let mut attempt = self.clone();
109            attempt.key = Some(candidate.clone());
110            attempt.raw_key = None;
111            match attempt.open_writer() {
112                Ok(conn) => return Ok((conn, index)),
113                Err(error) => {
114                    first_error.get_or_insert(error);
115                }
116            }
117        }
118        Err(first_error.unwrap_or(DbError::NoKeyOpened))
119    }
120
121    /// Open the writer; if the file is unreadable and the policy allows it, quarantine it and
122    /// open a fresh database at the same path.
123    pub fn open_writer_recovering(&self, policy: RecoveryPolicy) -> Result<OpenOutcome> {
124        match self.open_writer() {
125            Ok(conn) => Ok(OpenOutcome {
126                conn,
127                key_index: 0,
128                quarantined: None,
129            }),
130            Err(error) if is_unreadable(&error) && allowed(self, policy) => {
131                let moved =
132                    quarantine_database_with(&self.path, policy.quarantine_stamp, policy.sidecars)?;
133                let conn = self.open_writer()?;
134                Ok(OpenOutcome {
135                    conn,
136                    key_index: 0,
137                    quarantined: Some(moved),
138                })
139            }
140            Err(error) => Err(error),
141        }
142    }
143}
144
145pub(crate) fn allowed(options: &DbOptions, policy: RecoveryPolicy) -> bool {
146    if options.is_keyed() {
147        policy.quarantine_keyed
148    } else {
149        policy.quarantine_corrupt
150    }
151}
152
153/// True when the file is not a usable SQLite database: SQLite reports it as not-a-database or
154/// corrupt, or an open-time integrity check failed ([`DbError::Integrity`], from `Quick` or `Full`).
155/// Busy, locked, permission and other I/O failures are not unreadable. They are transient or
156/// environmental, and quarantining on them would move a healthy file aside.
157pub(crate) fn is_unreadable(error: &DbError) -> bool {
158    match error {
159        DbError::Integrity(_) => true,
160        DbError::Sqlite(rusqlite::Error::SqliteFailure(failure, _)) => matches!(
161            failure.code,
162            ErrorCode::NotADatabase | ErrorCode::DatabaseCorrupt
163        ),
164        _ => false,
165    }
166}
167
168/// Move `db` and its `-wal` / `-shm` / `-journal` sidecars to `<db>.corrupt-<unix-ms>[...]`.
169/// Returns the new main-file path. Nothing is deleted.
170pub fn quarantine_database(db: &Path) -> Result<PathBuf> {
171    quarantine_database_with(db, QuarantineStamp::Millis, SidecarPolicy::RenameSidecars)
172}
173
174/// [`quarantine_database`] with an explicit suffix format and sidecar policy. Refuses to overwrite
175/// an existing file at the destination. Sidecar failures are errors, not ignored: a `-wal` left
176/// at the original path would be replayed into the replacement database.
177pub fn quarantine_database_with(
178    db: &Path,
179    stamp: QuarantineStamp,
180    sidecars: SidecarPolicy,
181) -> Result<PathBuf> {
182    quarantine_database_opts(
183        db,
184        &QuarantineOptions {
185            name: stamp,
186            sidecars,
187            ..QuarantineOptions::default()
188        },
189    )
190}
191
192/// How a quarantined file leaves its original path.
193#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
194pub enum MoveMode {
195    /// `rename(2)`: one step, within the directory (default).
196    #[default]
197    Rename,
198    /// Hard-link the file to its destination, then remove the original, so the destination
199    /// exists before the original name goes away. Fails, with the original still in place, where
200    /// hard links are not supported.
201    HardLinkThenRemove,
202}
203
204/// Options for [`quarantine_database_opts`]. `Default` matches [`quarantine_database`].
205#[derive(Clone, Debug, Default)]
206pub struct QuarantineOptions {
207    /// Suffix format appended to the database file name.
208    pub name: QuarantineStamp,
209    /// What happens to the `-wal`, `-shm` and `-journal` sidecars.
210    pub sidecars: SidecarPolicy,
211    /// How the main file and the sidecars leave their original names.
212    pub move_mode: MoveMode,
213    /// Recovery marker written atomically (owner-only) before any file moves. It is JSON:
214    /// `{"schema", "original", "quarantined", "unixMs"}`. It stays in place after success; the app
215    /// removes it when recovery is complete.
216    pub marker: Option<PathBuf>,
217}
218
219/// Attempts for a unique quarantine name under [`QuarantineStamp::Seconds`]: the base name, then
220/// `-1`, `-2`, ... up to `-(QUARANTINE_NAME_ATTEMPTS - 1)`.
221const QUARANTINE_NAME_ATTEMPTS: u32 = 100;
222
223/// [`quarantine_database`] with full [`QuarantineOptions`]. [`QuarantineStamp::Millis`] refuses an
224/// existing destination, and sidecar failures are errors for every stamp.
225///
226/// [`QuarantineStamp::Seconds`] and [`QuarantineStamp::Custom`] names are unique: a second
227/// quarantine that takes the same name gets `-1`, `-2`, and so on (up to 99 retries), so a
228/// quarantine within the same second takes `<db>.corrupt-<secs>-1`. A name is claimed with
229/// create-new semantics (`rename_exclusive`, or `link` for [`MoveMode::HardLinkThenRemove`]), never
230/// by check-then-rename, so a concurrent quarantine cannot be overwritten.
231pub fn quarantine_database_opts(db: &Path, options: &QuarantineOptions) -> Result<PathBuf> {
232    quarantine_steps(db, options).map_err(|failure| failure.error)
233}
234
235/// File name suffix of the journal [`quarantine_database_journaled`] keeps while a quarantine is
236/// unfinished: `<db>.quarantine-journal`.
237pub(crate) const QUARANTINE_JOURNAL_SUFFIX: &str = ".quarantine-journal";
238
239/// A failed quarantine. `stranded_at` is `Some(dest)` only when part of the store moved and the
240/// move could not be undone: the main file is then at `dest`, with whatever sidecars moved.
241pub(crate) struct QuarantineFailure {
242    pub(crate) error: DbError,
243    pub(crate) stranded_at: Option<PathBuf>,
244}
245
246impl QuarantineFailure {
247    fn untouched(error: DbError) -> Self {
248        Self {
249            error,
250            stranded_at: None,
251        }
252    }
253}
254
255/// `<db>.quarantine-journal` when an earlier quarantine left it, which means the store is split
256/// between the original path and a quarantine name.
257pub(crate) fn unfinished_quarantine(db: &Path) -> Option<PathBuf> {
258    let journal = with_suffix(db, QUARANTINE_JOURNAL_SUFFIX);
259    std::fs::symlink_metadata(&journal).ok().map(|_| journal)
260}
261
262/// Quarantine with the steps journaled: the recovery marker `<db>.quarantine-journal` is written
263/// before any file moves, removed once the quarantine finished or was fully undone, and kept when
264/// a partial move could not be undone.
265// Crate-private: the failure carries the partial-move state the caller reports, so it is not boxed.
266#[allow(clippy::result_large_err)]
267pub(crate) fn quarantine_database_journaled(
268    db: &Path,
269    stamp: QuarantineStamp,
270    sidecars: SidecarPolicy,
271) -> std::result::Result<PathBuf, QuarantineFailure> {
272    let journal = with_suffix(db, QUARANTINE_JOURNAL_SUFFIX);
273    let result = quarantine_steps(
274        db,
275        &QuarantineOptions {
276            name: stamp,
277            sidecars,
278            marker: Some(journal.clone()),
279            ..QuarantineOptions::default()
280        },
281    );
282    match &result {
283        Err(failure) if failure.stranded_at.is_some() => {}
284        _ => {
285            let _ = std::fs::remove_file(&journal);
286        }
287    }
288    result
289}
290
291/// One move of a quarantine, kept so it can be undone.
292struct Moved {
293    from: PathBuf,
294    to: PathBuf,
295}
296
297#[allow(clippy::result_large_err)]
298fn quarantine_steps(
299    db: &Path,
300    options: &QuarantineOptions,
301) -> std::result::Result<PathBuf, QuarantineFailure> {
302    let name = db
303        .file_name()
304        .ok_or(DbError::InvalidDatabasePath)
305        .map_err(QuarantineFailure::untouched)?
306        .to_string_lossy()
307        .into_owned();
308    let suffix = options.name.suffix(SystemTime::now());
309    if suffix.is_empty() || suffix.contains(['/', '\\', '\0']) {
310        return Err(QuarantineFailure::untouched(
311            DbError::InvalidQuarantineSuffix(suffix),
312        ));
313    }
314    let attempts = match options.name {
315        QuarantineStamp::Seconds | QuarantineStamp::Custom(_) => QUARANTINE_NAME_ATTEMPTS,
316        QuarantineStamp::Millis => 1,
317    };
318    let mut last = None;
319    for attempt in 0..attempts {
320        let dest = quarantine_candidate(db, &name, &suffix, attempt);
321        if quarantine_name_taken(&dest, options.sidecars) {
322            last = Some(dest);
323            continue;
324        }
325        if let Some(marker) = &options.marker {
326            write_marker(marker, db, &dest).map_err(QuarantineFailure::untouched)?;
327        }
328        match move_file(db, &dest, options.move_mode) {
329            Ok(()) => {}
330            // Another quarantine claimed the name after the check: try the next one.
331            Err(error)
332                if error.kind() == std::io::ErrorKind::AlreadyExists && attempt + 1 < attempts =>
333            {
334                last = Some(dest);
335                continue;
336            }
337            Err(error) => {
338                // The main file did not move, so nothing did.
339                return Err(QuarantineFailure::untouched(DbError::Fs(
340                    rightkit_fs::FsError::io(db, error),
341                )));
342            }
343        }
344        let mut done = vec![Moved {
345            from: db.to_path_buf(),
346            to: dest.clone(),
347        }];
348        let mut deleted = false;
349        for sidecar in ["-wal", "-shm", "-journal"] {
350            let from = with_suffix(db, sidecar);
351            let step = match options.sidecars {
352                SidecarPolicy::RenameSidecars => {
353                    let to = with_suffix(&dest, sidecar);
354                    match move_if_present(&from, &to, options.move_mode) {
355                        Ok(true) => {
356                            done.push(Moved { from, to });
357                            Ok(())
358                        }
359                        Ok(false) => Ok(()),
360                        Err(error) => Err(error),
361                    }
362                }
363                SidecarPolicy::DeleteSidecars => match remove_if_present(&from) {
364                    Ok(removed) => {
365                        deleted |= removed;
366                        Ok(())
367                    }
368                    Err(error) => Err(error),
369                },
370            };
371            if let Err(error) = step {
372                return Err(undo_quarantine(db, &dest, done, deleted, error, options));
373            }
374        }
375        return Ok(dest);
376    }
377    let dest = last.unwrap_or_else(|| quarantine_candidate(db, &name, &suffix, 0));
378    Err(QuarantineFailure::untouched(DbError::Fs(
379        rightkit_fs::FsError::io(
380            &dest,
381            std::io::Error::new(
382                std::io::ErrorKind::AlreadyExists,
383                "quarantine destination already exists",
384            ),
385        ),
386    )))
387}
388
389/// A sidecar step failed after the main file moved. Move everything back in reverse order. A
390/// deleted sidecar cannot come back, so after one the move is not undone. If any undo step
391/// fails, the journal stays and the failure names where the main file is.
392fn undo_quarantine(
393    db: &Path,
394    dest: &Path,
395    done: Vec<Moved>,
396    deleted: bool,
397    error: DbError,
398    options: &QuarantineOptions,
399) -> QuarantineFailure {
400    let stranded = |reason: String| QuarantineFailure {
401        error: DbError::Fs(rightkit_fs::FsError::io(
402            dest,
403            std::io::Error::other(format!(
404                "quarantine incomplete: {error}; {reason}; the store is split between {} and {}",
405                db.display(),
406                dest.display()
407            )),
408        )),
409        stranded_at: Some(dest.to_path_buf()),
410    };
411    if deleted {
412        return stranded("a deleted sidecar cannot be restored, so the move was not undone".into());
413    }
414    for step in done.iter().rev() {
415        if let Err(e) = unmove_file(&step.to, &step.from, options.move_mode) {
416            return stranded(format!("rolling back {} failed: {e}", step.from.display()));
417        }
418    }
419    if let Some(marker) = &options.marker {
420        let _ = std::fs::remove_file(marker);
421    }
422    QuarantineFailure::untouched(error)
423}
424
425/// Undo [`move_file`]: bring `to` back to `from`, never overwriting.
426fn unmove_file(to: &Path, from: &Path, mode: MoveMode) -> std::io::Result<()> {
427    move_file(to, from, mode)
428}
429
430/// The `attempt`-th destination for a quarantined `db`: `<db><suffix>`, then `<db><suffix>-1`, ...
431fn quarantine_candidate(db: &Path, name: &str, suffix: &str, attempt: u32) -> PathBuf {
432    if attempt == 0 {
433        db.with_file_name(format!("{name}{suffix}"))
434    } else {
435        db.with_file_name(format!("{name}{suffix}-{attempt}"))
436    }
437}
438
439/// True when the destination, or (under [`SidecarPolicy::RenameSidecars`]) one of its sidecar
440/// names, already exists. A dangling link counts as existing.
441fn quarantine_name_taken(dest: &Path, sidecars: SidecarPolicy) -> bool {
442    fn exists(path: &Path) -> bool {
443        std::fs::symlink_metadata(path).is_ok()
444    }
445    exists(dest)
446        || (sidecars == SidecarPolicy::RenameSidecars
447            && ["-wal", "-shm", "-journal"]
448                .iter()
449                .any(|sidecar| exists(&with_suffix(dest, sidecar))))
450}
451
452/// Moves `from` to `to`, failing with `AlreadyExists` (and leaving both names) when `to` exists.
453/// Both modes claim `to` atomically: `rename_exclusive` for `Rename`, `link` for `HardLinkThenRemove`.
454fn move_file(from: &Path, to: &Path, mode: MoveMode) -> std::io::Result<()> {
455    match mode {
456        MoveMode::Rename => rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from),
457        MoveMode::HardLinkThenRemove => {
458            std::fs::hard_link(from, to)?;
459            std::fs::remove_file(from)
460        }
461    }
462}
463
464/// `Ok(true)` when the file moved, `Ok(false)` when there was nothing to move.
465fn move_if_present(from: &Path, to: &Path, mode: MoveMode) -> Result<bool> {
466    match move_file(from, to, mode) {
467        Ok(()) => Ok(true),
468        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
469        Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(from, error))),
470    }
471}
472
473fn write_marker(marker: &Path, original: &Path, quarantined: &Path) -> Result<()> {
474    let unix_ms = SystemTime::now()
475        .duration_since(UNIX_EPOCH)
476        .unwrap_or_default()
477        .as_millis();
478    let body = format!(
479        "{{\"schema\":\"rightkit-db-quarantine.v1\",\"original\":{},\"quarantined\":{},\"unixMs\":{}}}\n",
480        json_string(&original.to_string_lossy()),
481        json_string(&quarantined.to_string_lossy()),
482        unix_ms
483    );
484    rightkit_fs::atomic_write_private(marker, body.as_bytes())?;
485    Ok(())
486}
487
488/// A JSON string literal for `value`, escaping quotes, backslashes and control characters.
489fn json_string(value: &str) -> String {
490    let mut out = String::with_capacity(value.len() + 2);
491    out.push('"');
492    for c in value.chars() {
493        match c {
494            '"' => out.push_str("\\\""),
495            '\\' => out.push_str("\\\\"),
496            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
497            c => out.push(c),
498        }
499    }
500    out.push('"');
501    out
502}
503
504fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
505    let mut name = path.as_os_str().to_os_string();
506    name.push(suffix);
507    PathBuf::from(name)
508}
509
510/// `Ok(true)` when the file was removed, `Ok(false)` when it was not there.
511fn remove_if_present(path: &Path) -> Result<bool> {
512    match std::fs::remove_file(path) {
513        Ok(()) => Ok(true),
514        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
515        Err(error) => Err(DbError::Fs(rightkit_fs::FsError::io(path, error))),
516    }
517}
518
519/// Rotate the SQLCipher key in place (`PRAGMA rekey`, atomic inside SQLCipher) and verify the
520/// connection still reads. The caller stages the new key in its credential store *before* calling
521/// this and promotes it after; an interruption between is recovered with
522/// [`DbOptions::open_writer_with_key_candidates`].
523#[cfg(feature = "cipher")]
524pub fn rekey(conn: &Connection, new_key: &str) -> Result<()> {
525    crate::validate_key(Some(new_key))?;
526    let statement = Zeroizing::new(format!("PRAGMA rekey = '{new_key}';"));
527    conn.execute_batch(statement.as_str())?;
528    crate::verify_key(conn)
529}