Skip to main content

rightkit_db/
backup.rs

1//! Crash-safe online backups.
2//!
3//! SQLite's Online Backup API copies through the source connection's own pager, so committed
4//! transactions still sitting in the `-wal` are included (a raw file copy would miss them).
5//! The copy lands in a sibling temp file, is checked with `PRAGMA quick_check` (re-opened
6//! with the same key for SQLCipher), narrowed to owner-only, `fsync`ed, and only then renamed
7//! onto the target: a crash leaves the previous target untouched and at worst an orphan temp
8//! file that `rightkit_fs::sweep_stale_temps` reclaims. Donor: CodeRight `private_fs::backup`.
9
10use crate::{apply_key_first, DbError, DbOptions, Result};
11use rusqlite::backup::{Backup, StepResult};
12use rusqlite::Connection;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, Instant};
16
17static COUNTER: AtomicU64 = AtomicU64::new(0);
18
19pub trait BackupProgress {
20    /// Called after every step with the pages remaining; return `false` to cancel.
21    fn step(&mut self, remaining: i32) -> bool;
22}
23
24/// Back `source` up to `target`. `options` supplies the SQLCipher key (if any) so the backup is
25/// encrypted with the same key; its `path` is ignored.
26///
27/// The key is validated before any staging exists, and the staging file is created
28/// owner-only with `create_private_new` before SQLite opens it, so plaintext is never
29/// exposed under a permissive parent. The target is untouched until the staged copy has
30/// passed `quick_check`, been synced, and is published by rename. Busy/locked steps retry
31/// only until a finite monotonic operation deadline (`options.busy_timeout`,
32/// checked before every step and backoff, with or without a progress callback); every
33/// sleep is capped to the remaining budget, and cancellation or expiry removes only this
34/// call's still-owned staging.
35pub fn online_backup_to(
36    options: &DbOptions,
37    source: &Connection,
38    target: &Path,
39    mut progress: Option<&mut dyn BackupProgress>,
40) -> Result<()> {
41    let started = Instant::now();
42    let deadline = started.checked_add(options.busy_timeout).unwrap_or(started);
43    let check_deadline = || -> Result<()> {
44        if Instant::now() >= deadline {
45            Err(DbError::Backup("backup operation timeout expired".into()))
46        } else {
47            Ok(())
48        }
49    };
50    // Key validation first: an invalid/unavailable key creates no staging at all.
51    let key = options.key_pragma_value()?;
52    check_deadline()?;
53    let (temp, created) = create_private_staging(target)?;
54    let result = (|| -> Result<()> {
55        // The staged file must still be the one this call exclusively created.
56        if let Some(want) = created {
57            if rightkit_fs::FileKey::of_path(&temp).ok() != Some(want) {
58                return Err(DbError::Backup(
59                    "backup staging was replaced before it was opened".into(),
60                ));
61            }
62        }
63        let mut dst = Connection::open(&temp)?;
64        // Never let SQLite spend a second independent timeout inside a backup step.
65        dst.busy_timeout(Duration::ZERO)?;
66        apply_key_first(&dst, key.as_ref().map(|k| k.as_str()))?;
67        {
68            let backup = Backup::new(source, &mut dst)?;
69            loop {
70                check_deadline()?;
71                let step = backup.step(512)?;
72                check_deadline()?;
73                if let Some(sink) = progress.as_deref_mut() {
74                    if !sink.step(backup.progress().remaining) {
75                        return Err(DbError::Backup("cancelled".into()));
76                    }
77                }
78                check_deadline()?;
79                match step {
80                    StepResult::Done => break,
81                    StepResult::More => {}
82                    // Source busy/locked: back off within the same operation deadline.
83                    _ => {
84                        let now = Instant::now();
85                        let remaining = deadline.saturating_duration_since(now);
86                        if remaining.is_zero() {
87                            return Err(DbError::Backup("backup busy timeout expired".into()));
88                        }
89                        std::thread::sleep(remaining.min(Duration::from_millis(25)));
90                    }
91                }
92            }
93        }
94        check_deadline()?;
95        // Leave one self-contained file: fold any WAL into the main file and drop WAL mode.
96        let _ = dst.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()));
97        dst.query_row("PRAGMA journal_mode=DELETE", [], |row| {
98            row.get::<_, String>(0)
99        })?;
100        let report: String = dst.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
101        if !report.trim().eq_ignore_ascii_case("ok") {
102            return Err(DbError::Backup(format!(
103                "quick_check on the backup said: {report}"
104            )));
105        }
106        drop(dst);
107        rightkit_fs::restrict_owner_only(&temp)?;
108        // Windows FlushFileBuffers needs a writable handle; a read-only open fails "Access is denied".
109        std::fs::OpenOptions::new()
110            .write(true)
111            .open(&temp)
112            .and_then(|f| f.sync_all())
113            .map_err(|e| DbError::Backup(format!("fsync backup: {e}")))?;
114        check_deadline()?;
115        std::fs::rename(&temp, target)
116            .map_err(|e| DbError::Backup(format!("publish backup: {e}")))?;
117        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
118            rightkit_fs::atomic::sync_dir(parent);
119        }
120        Ok(())
121    })();
122    if result.is_err() {
123        remove_owned_staging(&temp, created);
124    }
125    result
126}
127
128/// Exclusively creates a unique owner-only staging file beside `target`, retrying on a
129/// name collision, and returns its path with the identity captured at creation. The file
130/// is private from its first observation, before SQLite opens it.
131fn create_private_staging(target: &Path) -> Result<(PathBuf, Option<rightkit_fs::FileKey>)> {
132    for _ in 0..32 {
133        let temp = temp_path(target)?;
134        match rightkit_fs::create_private_new(&temp) {
135            Ok(file) => {
136                let key = rightkit_fs::FileKey::of_file(&file, &temp).ok();
137                drop(file);
138                return Ok((temp, key));
139            }
140            Err(error) if error.kind() == Some(std::io::ErrorKind::AlreadyExists) => {}
141            Err(error) => return Err(error.into()),
142        }
143    }
144    Err(DbError::Backup(
145        "could not create a unique backup staging file".into(),
146    ))
147}
148
149/// Removes this call's staging family only while the main staged file still has the
150/// identity captured at its exclusive creation (or is already gone). A replaced staging
151/// file is another owner's and is retained whole.
152fn remove_owned_staging(temp: &Path, created: Option<rightkit_fs::FileKey>) {
153    if let Some(want) = created {
154        match rightkit_fs::FileKey::of_path(temp) {
155            Ok(now) if now != want => return,
156            _ => {}
157        }
158    }
159    remove_with_sidecars(temp);
160}
161
162/// Validate `backup_path` before copying it into an already-open `destination`.
163/// Uses the SQLite backup API, including committed WAL contents. `options` supplies
164/// the backup's key and timeout; its path is ignored. Invalid backups never touch
165/// `destination`. The destination must be writable and outside a transaction.
166pub fn restore_from(
167    options: &DbOptions,
168    destination: &mut Connection,
169    backup_path: &Path,
170) -> Result<()> {
171    let source = validated_backup(options, backup_path)?;
172    restore_validated(options, &source, destination)
173}
174
175impl DbOptions {
176    /// Validate the backup first, then open the existing live database without
177    /// bootstrap and restore it. Neither missing backup nor missing live DB is created.
178    /// The backup must use the same SQLCipher key as these options.
179    pub fn restore_from(&self, backup_path: impl AsRef<Path>) -> Result<()> {
180        let source = validated_backup(self, backup_path.as_ref())?;
181        let mut destination = self.open_existing_writer()?;
182        restore_validated(self, &source, &mut destination)
183    }
184}
185
186pub(crate) fn validated_backup(options: &DbOptions, path: &Path) -> Result<Connection> {
187    let source = options.open_existing_at(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
188    // Keep the checked snapshot stable until Backup has finished reading it.
189    source.execute_batch("BEGIN;")?;
190    let messages = {
191        let mut statement = source.prepare("PRAGMA integrity_check")?;
192        let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
193        rows.collect::<rusqlite::Result<Vec<_>>>()?
194    };
195    if messages.is_empty()
196        || messages
197            .iter()
198            .any(|message| !message.trim().eq_ignore_ascii_case("ok"))
199    {
200        return Err(DbError::Integrity(messages.join("; ")));
201    }
202    Ok(source)
203}
204
205fn restore_validated(
206    options: &DbOptions,
207    source: &Connection,
208    destination: &mut Connection,
209) -> Result<()> {
210    let backup = Backup::new(source, destination)?;
211    let started = Instant::now();
212    let deadline = started.checked_add(options.busy_timeout);
213    loop {
214        match backup.step(512)? {
215            StepResult::Done => return Ok(()),
216            StepResult::More => {}
217            StepResult::Busy | StepResult::Locked => {
218                let Some(deadline) = deadline else {
219                    std::thread::sleep(Duration::from_millis(25));
220                    continue;
221                };
222                let remaining = deadline.saturating_duration_since(Instant::now());
223                if remaining.is_zero() {
224                    return Err(DbError::Backup("restore busy timeout expired".into()));
225                }
226                std::thread::sleep(remaining.min(Duration::from_millis(25)));
227            }
228            _ => return Err(DbError::Backup("unexpected restore step result".into())),
229        }
230    }
231}
232
233fn temp_path(target: &Path) -> Result<PathBuf> {
234    let name = target
235        .file_name()
236        .ok_or(DbError::InvalidDatabasePath)?
237        .to_string_lossy();
238    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
239    Ok(target.with_file_name(format!(
240        ".{name}{}{}-{n}",
241        rightkit_fs::atomic::TEMP_MARKER,
242        std::process::id()
243    )))
244}
245
246fn remove_with_sidecars(path: &Path) {
247    let _ = remove_database_files(path);
248}
249
250/// Delete a database file and its `-wal`, `-shm` and `-journal` sidecars.
251///
252/// A file that does not exist is not an error. Every file is attempted even if an earlier one
253/// fails; the first real failure is returned. On Windows a file still open by a connection
254/// cannot be deleted, so drop the writer connection and close the read pool first
255/// (`ReadPool::close_and_wait` releases every pooled reader).
256pub fn remove_database_files(path: &Path) -> std::io::Result<()> {
257    let mut first_error = None;
258    let mut remove = |p: &Path| {
259        if let Err(e) = std::fs::remove_file(p) {
260            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
261                first_error = Some(e);
262            }
263        }
264    };
265    remove(path);
266    for suffix in ["-wal", "-shm", "-journal"] {
267        let mut name = path.as_os_str().to_os_string();
268        name.push(suffix);
269        remove(&PathBuf::from(name));
270    }
271    first_error.map_or(Ok(()), Err)
272}
273
274/// The lock file that guards the whole store behind the database at `db_path`:
275/// `<db_path>.store-lock`.
276pub fn store_lock_path(db_path: &Path) -> PathBuf {
277    let mut name = db_path.as_os_str().to_os_string();
278    name.push(".store-lock");
279    PathBuf::from(name)
280}
281
282/// A held store-wide lock (see [`store_lock_path`]). Released on drop.
283///
284/// Processes that use the database (daemon, shell) hold a [`StoreLock::shared`] guard for as long
285/// as they keep connections open; [`replace_database`] takes the [`StoreLock::exclusive`] guard,
286/// so a replace runs only when no cooperating process is using the store. The lock is advisory
287/// and cooperative: a process that never takes the shared guard is not excluded.
288#[derive(Debug)]
289pub struct StoreLock {
290    lock: rightkit_fs::FileLock,
291}
292
293impl StoreLock {
294    /// Shared guard: any number of holders, excluded only by an exclusive holder. Waits per
295    /// `options.timeout` and returns `DbError::Fs` (locked or timed out) when busy.
296    pub fn shared(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
297        let lock = rightkit_fs::FileLock::acquire_shared(&store_lock_path(db_path), options)?;
298        Ok(Self { lock })
299    }
300
301    /// Exclusive guard: excludes every shared and exclusive holder. Waits per `options.timeout`.
302    pub fn exclusive(db_path: &Path, options: &rightkit_fs::LockOptions) -> Result<Self> {
303        let lock = rightkit_fs::FileLock::acquire(&store_lock_path(db_path), options)?;
304        Ok(Self { lock })
305    }
306
307    /// Path of the lock file.
308    pub fn path(&self) -> &Path {
309        self.lock.path()
310    }
311}
312
313/// Atomically replace the database at `options.path` with the database at `replacement`.
314///
315/// Under the store-wide exclusive lock ([`StoreLock::exclusive`], waiting per `lock`):
316/// 1. `replacement` is opened read-only with `options`' key and every `PRAGMA integrity_check`
317///    row must be `ok` (`DbError::Integrity` otherwise; the live database is untouched).
318/// 2. Its committed contents (WAL included) are copied through SQLite's backup API into a
319///    staging file beside the live database, checked, narrowed to owner-only and synced.
320/// 3. The live database's `-wal`, `-shm` and `-journal` sidecars are removed, so a stale log
321///    can never be replayed onto the new file, and the staging file is renamed onto the path.
322///
323/// A failure in step 1 or 2 leaves the live database untouched and removes the staging file. The
324/// only crash window is between removing the sidecars and the rename in step 3: the old main
325/// file is then still in place without its uncheckpointed log, and the staging file remains
326/// (reclaimed by `rightkit_fs::sweep_stale_temps`). The replacement keeps the options' key, so
327/// it must be encrypted with the same key.
328///
329/// Every connection to the live database, in this process and in any other, must be closed
330/// before the call: other processes by holding only [`StoreLock::shared`] guards (the exclusive
331/// guard cannot be taken while one is held), this process by dropping its writer and calling
332/// `ReadPool::close_and_wait`. On Windows an open connection also makes the rename fail.
333pub fn replace_database(
334    options: &DbOptions,
335    replacement: &Path,
336    lock: &rightkit_fs::LockOptions,
337) -> Result<()> {
338    let _guard = StoreLock::exclusive(&options.path, lock)?;
339    replace_database_locked(options, replacement)
340}
341
342/// [`replace_database`] for a caller that already holds the exclusive [`StoreLock`] for
343/// `options.path` (for example to do more work under the same lock).
344pub fn replace_database_locked(options: &DbOptions, replacement: &Path) -> Result<()> {
345    let target = options.path.as_path();
346    let source = validated_backup(options, replacement)?;
347    let staging = temp_path(target)?;
348    let staged = (|| -> Result<()> {
349        online_backup_to(options, &source, &staging, None)?;
350        drop(source);
351        remove_database_files_except_main(target)
352            .map_err(|e| DbError::Backup(format!("clear live sidecars: {e}")))?;
353        std::fs::rename(&staging, target)
354            .map_err(|e| DbError::Backup(format!("publish replacement: {e}")))?;
355        if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
356            rightkit_fs::atomic::sync_dir(parent);
357        }
358        Ok(())
359    })();
360    if staged.is_err() {
361        let _ = std::fs::remove_file(&staging);
362    }
363    staged
364}
365
366fn remove_database_files_except_main(path: &Path) -> std::io::Result<()> {
367    let mut first_error = None;
368    for suffix in ["-wal", "-shm", "-journal"] {
369        let mut name = path.as_os_str().to_os_string();
370        name.push(suffix);
371        if let Err(e) = std::fs::remove_file(PathBuf::from(name)) {
372            if e.kind() != std::io::ErrorKind::NotFound && first_error.is_none() {
373                first_error = Some(e);
374            }
375        }
376    }
377    first_error.map_or(Ok(()), Err)
378}