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