Skip to main content

rightkit_db/
restore.rs

1//! A restore transaction: replace the live database with a prepared copy, keep the previous store
2//! at a named path, and put it back if anything fails.
3//!
4//! [`replace_database`](crate::replace_database) drops the old file. A restore must not: the
5//! previous store is the user's data until the new one is proven. Under the store-wide exclusive
6//! lock [`restore_database`]:
7//!
8//! 1. recovers a restore a crash left half done (see [`recover_interrupted_restore`]);
9//! 2. checks the replacement (opened read-only with the options' key, every `integrity_check` row
10//!    `ok`) before touching anything, so a bad replacement leaves the live store alone;
11//! 3. writes a journal `<db>.restore-journal` naming where the previous store will be kept, then
12//!    moves the live file and its `-wal`, `-shm`, `-journal` sidecars to
13//!    `<db><previous_infix><unix-ms>` (claimed exclusively, never overwriting, never deleted);
14//! 4. places the replacement with [`replace_database_locked`](crate::replace_database_locked);
15//! 5. runs [`RestoreOptions::verify`] on the placed store;
16//! 6. removes the journal and returns a [`RestoreReceipt`] naming the kept path.
17//!
18//! A failure in steps 3 to 5 sets the placed copy aside at `<db><failed_infix><unix-ms>` (also
19//! kept) and moves the previous store back. If that rollback itself fails, the journal stays and
20//! the error says where each file is; [`recover_interrupted_restore`] (also run first by every
21//! restore) puts the previous store back on the next open.
22//!
23//! The journal is a small text file: `rightkit-db-restore.v1`, `state=quarantining|placed`,
24//! `previous=<file name>` (or `previous=none` when there was no store). Recovery acts only on a
25//! journal this module wrote, never on a stray `.pre-restore-*` file.
26//!
27//! Callers must close every connection to the live database first (required on Windows), as for
28//! `replace_database`. The replacement keeps the options' key.
29
30use crate::backup::{replace_database_locked, validated_backup, StoreLock};
31use crate::{DbError, DbOptions, Result};
32use std::path::{Path, PathBuf};
33use std::time::{SystemTime, UNIX_EPOCH};
34
35const SIDECARS: [&str; 3] = ["-wal", "-shm", "-journal"];
36const JOURNAL_MAGIC: &str = "rightkit-db-restore.v1";
37const NAME_ATTEMPTS: u32 = 100;
38
39/// A check run on the placed store; `Err(reason)` rolls the restore back. The `DbOptions` are the
40/// ones given to the restore. Drop every connection the check opens before it returns.
41pub type RestoreVerify<'a> = &'a dyn Fn(&DbOptions) -> std::result::Result<(), String>;
42
43/// Names and the optional check for [`restore_database`].
44#[derive(Clone, Copy)]
45pub struct RestoreOptions<'a> {
46    /// The previous store is kept as `<db><previous_infix><unix-ms>`. Default `.pre-restore-`.
47    pub previous_infix: &'a str,
48    /// A restored copy that failed its check is kept as `<db><failed_infix><unix-ms>`. Default
49    /// `.restore-failed-`.
50    pub failed_infix: &'a str,
51    /// Check the placed store. `None` trusts the integrity check made before placing.
52    pub verify: Option<RestoreVerify<'a>>,
53}
54
55impl Default for RestoreOptions<'_> {
56    fn default() -> Self {
57        Self {
58            previous_infix: ".pre-restore-",
59            failed_infix: ".restore-failed-",
60            verify: None,
61        }
62    }
63}
64
65/// What a finished restore left behind, for a receipt.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct RestoreReceipt {
68    /// Where the previous store is kept (main file; sidecars sit beside it), `None` when there was
69    /// no store before.
70    pub previous: Option<PathBuf>,
71}
72
73/// What [`recover_interrupted_restore`] found and did.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub enum InterruptedRestore {
76    /// The live store was missing; the previous store kept at `from` was moved back (sidecars
77    /// included).
78    PutBack { from: PathBuf },
79    /// The replacement was already placed when the crash happened. It stays; the previous store
80    /// is still kept at `previous`. Nothing was verified, so the caller may run its own checks.
81    KeptPlaced { previous: PathBuf },
82}
83
84/// [`restore_database`] with the store-wide exclusive lock held for the call, waiting per `lock`.
85pub fn restore_database(
86    options: &DbOptions,
87    replacement: &Path,
88    restore: &RestoreOptions<'_>,
89    lock: &rightkit_fs::LockOptions,
90) -> Result<RestoreReceipt> {
91    let _guard = StoreLock::exclusive(&options.path, lock)?;
92    restore_database_locked(options, replacement, restore)
93}
94
95/// The restore transaction for a caller that already holds the exclusive [`StoreLock`].
96pub fn restore_database_locked(
97    options: &DbOptions,
98    replacement: &Path,
99    restore: &RestoreOptions<'_>,
100) -> Result<RestoreReceipt> {
101    let live = options.path.as_path();
102    recover_interrupted_restore_locked(options)?;
103    if same_file(live, replacement) {
104        return Err(failed(
105            "the replacement is the live database".into(),
106            None,
107            None,
108            true,
109        ));
110    }
111    // Nothing has moved yet: a bad replacement fails here and the live store is untouched.
112    drop(validated_backup(options, replacement)?);
113
114    let journal = journal_path(live);
115    let previous = if present(live) {
116        Some(free_name(live, restore.previous_infix)?)
117    } else {
118        None
119    };
120    write_journal(&journal, "quarantining", previous.as_deref())
121        .map_err(|e| failed(format!("write the restore journal: {e}"), None, None, true))?;
122
123    if let Some(prev) = &previous {
124        if let Err(e) = move_store(live, prev) {
125            // The main file moves first, so a failure leaves nothing or the main file moved.
126            return Err(rollback(
127                live,
128                Some(prev),
129                restore,
130                format!("could not move the current store aside: {e}"),
131            ));
132        }
133    }
134    if let Err(e) = replace_database_locked(options, replacement) {
135        return Err(rollback(
136            live,
137            previous.as_deref(),
138            restore,
139            format!("could not put the restored store in place: {e}"),
140        ));
141    }
142    if let Err(e) = write_journal(&journal, "placed", previous.as_deref()) {
143        return Err(rollback(
144            live,
145            previous.as_deref(),
146            restore,
147            format!("update the restore journal: {e}"),
148        ));
149    }
150    if let Some(verify) = restore.verify {
151        if let Err(reason) = verify(options) {
152            return Err(rollback(
153                live,
154                previous.as_deref(),
155                restore,
156                format!("the restored store failed its checks: {reason}"),
157            ));
158        }
159    }
160    let _ = std::fs::remove_file(&journal);
161    rightkit_fs::atomic::sync_dir(parent_of(live));
162    Ok(RestoreReceipt { previous })
163}
164
165/// [`recover_interrupted_restore_locked`] under the exclusive store lock. Call it before opening
166/// the database at startup: a crash inside a restore can leave the live path empty with the
167/// previous store sitting at its kept name.
168pub fn recover_interrupted_restore(
169    options: &DbOptions,
170    lock: &rightkit_fs::LockOptions,
171) -> Result<Option<InterruptedRestore>> {
172    let _guard = StoreLock::exclusive(&options.path, lock)?;
173    recover_interrupted_restore_locked(options)
174}
175
176/// Finish a restore a crash left half done, driven by the restore journal. No journal returns
177/// `Ok(None)`.
178///
179/// - live store missing, previous kept: the previous store (main file and any sidecars still at
180///   the kept name) is moved back, whatever moved before the crash; the journal is removed.
181/// - previous main file gone from the kept name but its sidecars still there (a stop between the
182///   renames of a put-back, whether or not the live main file exists): the sidecars are moved
183///   back independently, and the journal is removed only after every kept file is back.
184/// - live store present and the previous kept too: the replacement was placed; both stay
185///   ([`InterruptedRestore::KeptPlaced`]); the journal is removed.
186/// - live store present, previous never moved: nothing happened; the journal is removed.
187///
188/// A state that fits none of these (the journal names a previous store that is gone while the
189/// live store is missing, or an unreadable journal) is `DbError::RestoreFailed` and nothing is
190/// touched.
191pub fn recover_interrupted_restore_locked(
192    options: &DbOptions,
193) -> Result<Option<InterruptedRestore>> {
194    let live = options.path.as_path();
195    let journal = journal_path(live);
196    let text = match std::fs::read_to_string(&journal) {
197        Ok(text) => text,
198        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
199        Err(e) => {
200            return Err(failed(
201                format!("read {}: {e}", journal.display()),
202                None,
203                None,
204                false,
205            ))
206        }
207    };
208    let previous = parse_journal(live, &text).map_err(|m| {
209        failed(
210            format!("{}: {m}; nothing was changed", journal.display()),
211            None,
212            None,
213            false,
214        )
215    })?;
216    let done = |outcome: Option<InterruptedRestore>| -> Result<Option<InterruptedRestore>> {
217        let _ = std::fs::remove_file(&journal);
218        rightkit_fs::atomic::sync_dir(parent_of(live));
219        Ok(outcome)
220    };
221    let Some(prev) = previous else {
222        return done(None);
223    };
224    let kept_main = present(&prev);
225    let kept_sidecars = SIDECARS.iter().any(|s| present(&with_suffix(&prev, s)));
226    match (present(live), kept_main, kept_sidecars) {
227        // The main file may already be back (or never left) while sidecars still sit at the kept
228        // name: a stop between the renames of `put_back`. Finish the sidecars; the journal goes
229        // only after every kept file is back.
230        (false, true, _) | (_, false, true) => match put_back(&prev, live) {
231            Ok(()) => done(Some(InterruptedRestore::PutBack { from: prev })),
232            Err(e) => Err(failed(
233                format!("the previous store could not be put back: {e}"),
234                Some(prev),
235                None,
236                false,
237            )),
238        },
239        (true, true, _) => done(Some(InterruptedRestore::KeptPlaced { previous: prev })),
240        (true, false, false) => done(None),
241        (false, false, false) => Err(failed(
242            format!(
243                "the journal names {} but neither it nor the live store exists",
244                prev.display()
245            ),
246            None,
247            None,
248            false,
249        )),
250    }
251}
252
253/// Undo after a failure past the point of moving: set a placed copy aside, move the previous store
254/// back, drop the journal. Returns the error to report.
255fn rollback(
256    live: &Path,
257    previous: Option<&Path>,
258    restore: &RestoreOptions<'_>,
259    message: String,
260) -> DbError {
261    let mut failed_copy = None;
262    if present(live) {
263        match free_name(live, restore.failed_infix).and_then(|dest| {
264            move_store(live, &dest)
265                .map(|()| dest)
266                .map_err(|e| failed(format!("{e}"), None, None, false))
267        }) {
268            Ok(dest) => failed_copy = Some(dest),
269            Err(e) => {
270                return failed(
271                    format!(
272                        "{message}; rollback incomplete: the restored copy could not be moved aside ({e})"
273                    ),
274                    previous.filter(|p| present(p)).map(Path::to_path_buf),
275                    None,
276                    false,
277                )
278            }
279        }
280    }
281    if let Some(prev) = previous {
282        if let Err(e) = put_back(prev, live) {
283            return failed(
284                format!(
285                    "{message}; rollback incomplete: the previous store could not be put back ({e})"
286                ),
287                Some(prev.to_path_buf()),
288                failed_copy,
289                false,
290            );
291        }
292    }
293    let _ = std::fs::remove_file(journal_path(live));
294    rightkit_fs::atomic::sync_dir(parent_of(live));
295    failed(message, None, failed_copy, true)
296}
297
298fn failed(
299    message: String,
300    previous: Option<PathBuf>,
301    failed_copy: Option<PathBuf>,
302    rolled_back: bool,
303) -> DbError {
304    DbError::RestoreFailed {
305        message,
306        previous,
307        failed_copy,
308        rolled_back,
309    }
310}
311
312fn journal_path(live: &Path) -> PathBuf {
313    with_suffix(live, ".restore-journal")
314}
315
316fn write_journal(path: &Path, state: &str, previous: Option<&Path>) -> Result<()> {
317    let name = previous
318        .and_then(Path::file_name)
319        .map_or_else(|| "none".to_string(), |n| n.to_string_lossy().into_owned());
320    let body = format!("{JOURNAL_MAGIC}\nstate={state}\nprevious={name}\n");
321    rightkit_fs::atomic::atomic_write_private(path, body.as_bytes())?;
322    Ok(())
323}
324
325/// The previous store's path (`None` for `previous=none`). The name must be a plain file name that
326/// starts with the live file's name, so a journal cannot point recovery elsewhere.
327fn parse_journal(live: &Path, text: &str) -> std::result::Result<Option<PathBuf>, String> {
328    let mut lines = text.lines();
329    if lines.next() != Some(JOURNAL_MAGIC) {
330        return Err("not a restore journal".into());
331    }
332    let (mut state, mut previous) = (None, None);
333    for line in lines {
334        if let Some(v) = line.strip_prefix("state=") {
335            state = Some(v);
336        } else if let Some(v) = line.strip_prefix("previous=") {
337            previous = Some(v);
338        }
339    }
340    if !matches!(state, Some("quarantining" | "placed")) {
341        return Err("unknown state".into());
342    }
343    let name = previous.ok_or("no previous= line")?;
344    if name == "none" {
345        return Ok(None);
346    }
347    let live_name = live
348        .file_name()
349        .ok_or("live path has no file name")?
350        .to_string_lossy();
351    if name.is_empty()
352        || name.contains(['/', '\\', '\0'])
353        || !name.starts_with(&*live_name)
354        || name == live_name
355    {
356        return Err(format!(
357            "previous= {name:?} is not a name beside the live store"
358        ));
359    }
360    Ok(Some(live.with_file_name(name)))
361}
362
363fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
364    let mut name = path.as_os_str().to_os_string();
365    name.push(suffix);
366    PathBuf::from(name)
367}
368
369fn parent_of(path: &Path) -> &Path {
370    path.parent()
371        .filter(|p| !p.as_os_str().is_empty())
372        .unwrap_or_else(|| Path::new("."))
373}
374
375fn present(path: &Path) -> bool {
376    std::fs::symlink_metadata(path).is_ok()
377}
378
379fn same_file(a: &Path, b: &Path) -> bool {
380    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
381        (Ok(a), Ok(b)) => a == b,
382        _ => a == b,
383    }
384}
385
386/// `<live><infix><unix-ms>`, then `-1`, `-2`, ...: a name where neither the main file nor a
387/// sidecar exists.
388fn free_name(live: &Path, infix: &str) -> Result<PathBuf> {
389    let name = live
390        .file_name()
391        .ok_or(DbError::InvalidDatabasePath)?
392        .to_string_lossy()
393        .into_owned();
394    if infix.is_empty() || infix.contains(['/', '\\', '\0']) {
395        return Err(DbError::InvalidQuarantineSuffix(infix.to_string()));
396    }
397    let ms = SystemTime::now()
398        .duration_since(UNIX_EPOCH)
399        .map_or(0, |d| d.as_millis());
400    for attempt in 0..NAME_ATTEMPTS {
401        let candidate = if attempt == 0 {
402            format!("{name}{infix}{ms}")
403        } else {
404            format!("{name}{infix}{ms}-{attempt}")
405        };
406        let dest = live.with_file_name(candidate);
407        if !present(&dest) && !SIDECARS.iter().any(|s| present(&with_suffix(&dest, s))) {
408            return Ok(dest);
409        }
410    }
411    Err(DbError::Fs(rightkit_fs::FsError::io(
412        live,
413        std::io::Error::new(
414            std::io::ErrorKind::AlreadyExists,
415            "no free name for the kept store",
416        ),
417    )))
418}
419
420/// Move the main file, then each sidecar that exists, to `to` and its sidecar names. Every move is
421/// create-new: nothing at the destination is overwritten.
422fn move_store(from: &Path, to: &Path) -> std::io::Result<()> {
423    rightkit_fs::rename_exclusive(from, to).map_err(std::io::Error::from)?;
424    for suffix in SIDECARS {
425        let (src, dst) = (with_suffix(from, suffix), with_suffix(to, suffix));
426        match rightkit_fs::rename_exclusive(&src, &dst) {
427            Ok(()) => {}
428            Err(e) => {
429                let e = std::io::Error::from(e);
430                if e.kind() != std::io::ErrorKind::NotFound {
431                    return Err(e);
432                }
433            }
434        }
435    }
436    Ok(())
437}
438
439/// Move a kept store back to `live`, which must be free. Tolerates a half-done earlier move: the
440/// main file moves if it is still at `kept`, and each sidecar still at `kept` moves with it.
441fn put_back(kept: &Path, live: &Path) -> std::io::Result<()> {
442    if present(kept) {
443        rightkit_fs::rename_exclusive(kept, live).map_err(std::io::Error::from)?;
444    }
445    for suffix in SIDECARS {
446        let (src, dst) = (with_suffix(kept, suffix), with_suffix(live, suffix));
447        if present(&src) {
448            rightkit_fs::rename_exclusive(&src, &dst).map_err(std::io::Error::from)?;
449        }
450    }
451    Ok(())
452}