Skip to main content

stateset_db/
maintenance.rs

1//! Backup and restore for embedded SQLite databases.
2//!
3//! This module provides the recovery half of the embedded deployment story:
4//! taking a consistent backup of a live database and restoring it safely.
5//!
6//! # Why not just copy the file?
7//!
8//! Copying `store.db` while writers are active produces a torn file: the
9//! `-wal` and `-shm` sidecars hold committed pages that are not yet in the
10//! main database, and page writes are not atomic with respect to `cp`.
11//! [`backup_to`] uses SQLite's `VACUUM INTO`, which runs inside a read
12//! transaction and writes a fully self-contained, already-compacted database
13//! that is consistent as of the transaction snapshot, even under concurrent
14//! writers.
15//!
16//! # Manifest
17//!
18//! Every backup is written with a sidecar manifest, `<backup>.manifest.json`,
19//! carrying the schema version (the highest applied migration name), migration
20//! count, engine version, creation time, source path, byte size and a SHA-256
21//! of the backup file. [`restore_from`] verifies the checksum before touching
22//! the target.
23//!
24//! # Restore safety rules
25//!
26//! 1. The manifest checksum must match the backup file byte-for-byte.
27//! 2. A backup whose `schema_version` is *newer* than the newest migration this
28//!    binary knows about is refused — restoring forward-migrated data into an
29//!    older engine would let it read columns it does not understand and write
30//!    rows the newer schema forbids.
31//! 3. An existing, non-empty target is never overwritten unless
32//!    [`RestoreOptions::overwrite`] is set.
33//! 4. The restore is atomic: the backup is copied to a temporary file in the
34//!    *same directory* as the target, fsynced, and then `rename`d into place,
35//!    so a crash mid-restore leaves either the old database or the new one,
36//!    never a half-written file.
37//!
38//! # Example
39//!
40//! ```ignore
41//! use stateset_db::maintenance::{backup_to, restore_from, RestoreOptions};
42//!
43//! let report = backup_to(&conn, "./store.db", "./backups/store-2026-07-20.db")?;
44//! println!("{} bytes, sha256 {}", report.manifest.size_bytes, report.manifest.checksum);
45//!
46//! let restored = restore_from(
47//!     "./backups/store-2026-07-20.db",
48//!     "./store.db",
49//!     &RestoreOptions { overwrite: true, ..Default::default() },
50//! )?;
51//! ```
52
53use chrono::{DateTime, Utc};
54use rusqlite::Connection;
55use serde::{Deserialize, Serialize};
56use sha2::{Digest, Sha256};
57use std::fs::{self, File};
58use std::io::{Read, Write};
59use std::path::{Path, PathBuf};
60
61/// Errors produced by backup and restore operations.
62#[derive(Debug, thiserror::Error)]
63#[non_exhaustive]
64pub enum MaintenanceError {
65    /// An I/O operation failed.
66    #[error("io error at {path}: {source}")]
67    Io {
68        /// Path being operated on.
69        path: PathBuf,
70        /// Underlying I/O error.
71        source: std::io::Error,
72    },
73    /// The SQLite engine reported an error.
74    #[error("sqlite error: {0}")]
75    Sqlite(#[from] rusqlite::Error),
76    /// The manifest could not be parsed or serialized.
77    #[error("manifest error: {0}")]
78    Manifest(String),
79    /// The backup file did not match the checksum recorded in its manifest.
80    #[error("checksum mismatch for {path}: manifest says {expected}, file is {actual}")]
81    ChecksumMismatch {
82        /// Backup file path.
83        path: PathBuf,
84        /// Checksum recorded in the manifest.
85        expected: String,
86        /// Checksum computed from the file on disk.
87        actual: String,
88    },
89    /// The backup was taken by a newer engine with migrations this binary lacks.
90    #[error(
91        "backup schema version '{backup}' is newer than this engine supports (latest known: '{known}'); \
92         restore with a build that includes the newer migrations"
93    )]
94    SchemaTooNew {
95        /// Schema version recorded in the backup manifest.
96        backup: String,
97        /// Latest migration known to this binary.
98        known: String,
99    },
100    /// The restore target already exists and `overwrite` was not set.
101    #[error(
102        "refusing to overwrite existing database at {path}; pass overwrite = true to replace it"
103    )]
104    TargetExists {
105        /// Target path.
106        path: PathBuf,
107    },
108}
109
110impl From<MaintenanceError> for stateset_core::CommerceError {
111    fn from(err: MaintenanceError) -> Self {
112        Self::DatabaseError(err.to_string())
113    }
114}
115
116type Result<T> = std::result::Result<T, MaintenanceError>;
117
118fn io(path: impl Into<PathBuf>) -> impl FnOnce(std::io::Error) -> MaintenanceError {
119    let path = path.into();
120    move |source| MaintenanceError::Io { path, source }
121}
122
123/// Metadata written alongside every backup as `<backup>.manifest.json`.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[non_exhaustive]
126pub struct BackupManifest {
127    /// Manifest format version.
128    pub manifest_version: u32,
129    /// Highest applied migration name, e.g. `066_search_configs`.
130    pub schema_version: String,
131    /// Number of migrations applied to the source database.
132    pub migration_count: usize,
133    /// Version of the engine that produced the backup.
134    pub engine_version: String,
135    /// When the backup was taken.
136    pub created_at: DateTime<Utc>,
137    /// Path of the database the backup was taken from.
138    pub source_path: String,
139    /// Size of the backup file in bytes.
140    pub size_bytes: u64,
141    /// Lowercase hex SHA-256 of the backup file.
142    pub checksum: String,
143}
144
145/// The manifest format version this build writes and accepts.
146pub const MANIFEST_VERSION: u32 = 1;
147
148/// Result of a successful [`backup_to`].
149#[derive(Debug, Clone)]
150#[non_exhaustive]
151pub struct BackupReport {
152    /// Path of the backup database file.
153    pub backup_path: PathBuf,
154    /// Path of the sidecar manifest.
155    pub manifest_path: PathBuf,
156    /// The manifest that was written.
157    pub manifest: BackupManifest,
158}
159
160/// Options controlling [`restore_from`].
161#[derive(Debug, Clone, Default)]
162pub struct RestoreOptions {
163    /// Replace an existing non-empty target database.
164    pub overwrite: bool,
165    /// Skip the manifest checksum verification (strongly discouraged;
166    /// intended only for recovering a backup whose manifest was lost).
167    pub skip_checksum: bool,
168    /// Allow restoring a backup whose schema is newer than this binary knows.
169    /// Unsafe — provided only as a break-glass escape hatch.
170    pub allow_newer_schema: bool,
171}
172
173/// Result of a successful [`restore_from`].
174#[derive(Debug, Clone)]
175#[non_exhaustive]
176pub struct RestoreReport {
177    /// Path the database was restored to.
178    pub target_path: PathBuf,
179    /// Schema version of the restored database.
180    pub schema_version: String,
181    /// Size of the restored database in bytes.
182    pub size_bytes: u64,
183    /// Whether the checksum was verified against the manifest.
184    pub checksum_verified: bool,
185    /// Whether an existing database was replaced.
186    pub replaced_existing: bool,
187}
188
189/// The conventional manifest path for a backup file.
190#[must_use]
191pub fn manifest_path_for(backup_path: &Path) -> PathBuf {
192    let mut name = backup_path.as_os_str().to_os_string();
193    name.push(".manifest.json");
194    PathBuf::from(name)
195}
196
197/// Compute the lowercase hex `SHA-256` of a file, streaming it in 64 `KiB` chunks.
198///
199/// # Errors
200///
201/// Returns an error if the file cannot be opened or read.
202pub fn file_checksum(path: &Path) -> Result<String> {
203    let mut file = File::open(path).map_err(io(path))?;
204    let mut hasher = Sha256::new();
205    let mut buf = vec![0_u8; 64 * 1024];
206    loop {
207        let read = file.read(&mut buf).map_err(io(path))?;
208        if read == 0 {
209            break;
210        }
211        hasher.update(&buf[..read]);
212    }
213    Ok(format!("{:x}", hasher.finalize()))
214}
215
216/// Read the applied schema version and migration count from a database.
217fn applied_schema(conn: &Connection) -> Result<(String, usize)> {
218    let table_exists: bool = conn.query_row(
219        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_migrations'",
220        [],
221        |row| row.get::<_, i64>(0),
222    )? > 0;
223    if !table_exists {
224        return Ok((String::new(), 0));
225    }
226    let count: i64 = conn.query_row("SELECT COUNT(*) FROM _migrations", [], |row| row.get(0))?;
227    let latest: Option<String> =
228        conn.query_row("SELECT MAX(name) FROM _migrations", [], |row| row.get(0))?;
229    Ok((latest.unwrap_or_default(), usize::try_from(count).unwrap_or(0)))
230}
231
232/// Take a consistent backup of `conn` into `backup_path` and write its manifest.
233///
234/// The parent directory of `backup_path` is created if needed. Any existing
235/// file at `backup_path` is rejected by SQLite's `VACUUM INTO`, which refuses
236/// to overwrite — pick a fresh path per backup.
237///
238/// # Errors
239///
240/// Returns an error if the vacuum fails, the file cannot be written, or the
241/// post-write checksum verification fails.
242pub fn backup_to(
243    conn: &Connection,
244    source_path: impl AsRef<Path>,
245    backup_path: impl AsRef<Path>,
246) -> Result<BackupReport> {
247    let backup_path = backup_path.as_ref();
248    if let Some(parent) = backup_path.parent().filter(|p| !p.as_os_str().is_empty()) {
249        fs::create_dir_all(parent).map_err(io(parent))?;
250    }
251
252    // VACUUM INTO takes a string literal, not a bound parameter, so the path is
253    // escaped by doubling single quotes. Embedded NULs would truncate the
254    // literal, so reject them outright.
255    let literal = backup_path.to_string_lossy().into_owned();
256    if literal.contains('\0') {
257        return Err(MaintenanceError::Manifest("backup path contains a NUL byte".to_owned()));
258    }
259    conn.execute_batch(&format!("VACUUM INTO '{}';", literal.replace('\'', "''")))?;
260
261    let (schema_version, migration_count) = applied_schema(conn)?;
262    let size_bytes = fs::metadata(backup_path).map_err(io(backup_path))?.len();
263    let checksum = file_checksum(backup_path)?;
264
265    let manifest = BackupManifest {
266        manifest_version: MANIFEST_VERSION,
267        schema_version,
268        migration_count,
269        engine_version: env!("CARGO_PKG_VERSION").to_owned(),
270        created_at: Utc::now(),
271        source_path: source_path.as_ref().to_string_lossy().into_owned(),
272        size_bytes,
273        checksum: checksum.clone(),
274    };
275
276    let manifest_path = manifest_path_for(backup_path);
277    let encoded = serde_json::to_vec_pretty(&manifest)
278        .map_err(|e| MaintenanceError::Manifest(e.to_string()))?;
279    write_file_durably(&manifest_path, &encoded)?;
280
281    // Verify what actually landed on disk, not what we think we wrote.
282    let verified = file_checksum(backup_path)?;
283    if verified != checksum {
284        return Err(MaintenanceError::ChecksumMismatch {
285            path: backup_path.to_path_buf(),
286            expected: checksum,
287            actual: verified,
288        });
289    }
290
291    Ok(BackupReport { backup_path: backup_path.to_path_buf(), manifest_path, manifest })
292}
293
294/// Load and parse the manifest sidecar for a backup file.
295///
296/// # Errors
297///
298/// Returns an error if the manifest is missing, unreadable, malformed, or
299/// written in an unsupported manifest format version.
300pub fn read_manifest(backup_path: &Path) -> Result<BackupManifest> {
301    let path = manifest_path_for(backup_path);
302    let bytes = fs::read(&path).map_err(io(&path))?;
303    let manifest: BackupManifest =
304        serde_json::from_slice(&bytes).map_err(|e| MaintenanceError::Manifest(e.to_string()))?;
305    if manifest.manifest_version > MANIFEST_VERSION {
306        return Err(MaintenanceError::Manifest(format!(
307            "manifest version {} is newer than supported version {MANIFEST_VERSION}",
308            manifest.manifest_version
309        )));
310    }
311    Ok(manifest)
312}
313
314/// True when `candidate` is a migration name this binary does not know about
315/// and which sorts after everything it does know. Migration names are
316/// zero-padded and monotonically increasing, so lexicographic order is the
317/// application order.
318fn is_schema_newer_than_known(candidate: &str) -> bool {
319    if candidate.is_empty() {
320        return false;
321    }
322    let known = crate::migrations::known_migration_names();
323    if known.contains(&candidate) {
324        return false;
325    }
326    known.last().is_none_or(|latest| candidate > *latest)
327}
328
329/// Restore a backup to `target_path` atomically.
330///
331/// See the [module docs](self) for the full list of safety rules.
332///
333/// # Errors
334///
335/// Returns [`MaintenanceError::ChecksumMismatch`] if the backup does not match
336/// its manifest, [`MaintenanceError::SchemaTooNew`] if the backup requires
337/// migrations this binary lacks, [`MaintenanceError::TargetExists`] if a
338/// non-empty target exists without `overwrite`, or an I/O error.
339pub fn restore_from(
340    backup_path: impl AsRef<Path>,
341    target_path: impl AsRef<Path>,
342    options: &RestoreOptions,
343) -> Result<RestoreReport> {
344    let backup_path = backup_path.as_ref();
345    let target_path = target_path.as_ref();
346
347    let backup_size = fs::metadata(backup_path).map_err(io(backup_path))?.len();
348
349    // 1. Manifest + checksum.
350    let mut schema_version = String::new();
351    let mut checksum_verified = false;
352    if options.skip_checksum {
353        // Still read the manifest opportunistically for the schema gate.
354        if let Ok(manifest) = read_manifest(backup_path) {
355            schema_version = manifest.schema_version;
356        }
357    } else {
358        let manifest = read_manifest(backup_path)?;
359        let actual = file_checksum(backup_path)?;
360        if actual != manifest.checksum {
361            return Err(MaintenanceError::ChecksumMismatch {
362                path: backup_path.to_path_buf(),
363                expected: manifest.checksum,
364                actual,
365            });
366        }
367        checksum_verified = true;
368        schema_version = manifest.schema_version;
369    }
370
371    // 2. Forward-restore gate.
372    if !options.allow_newer_schema && is_schema_newer_than_known(&schema_version) {
373        return Err(MaintenanceError::SchemaTooNew {
374            backup: schema_version,
375            known: crate::migrations::latest_known_migration().to_owned(),
376        });
377    }
378
379    // 3. Overwrite gate. A zero-byte target is treated as absent: SQLite
380    //    creates one on first connect, so refusing it would make restore into
381    //    a freshly-opened path impossible.
382    let existing_len = fs::metadata(target_path).map(|m| m.len()).ok();
383    let replaced_existing = matches!(existing_len, Some(len) if len > 0);
384    if replaced_existing && !options.overwrite {
385        return Err(MaintenanceError::TargetExists { path: target_path.to_path_buf() });
386    }
387
388    // 4. Atomic install: temp file in the same directory, fsync, rename.
389    let dir = target_path
390        .parent()
391        .filter(|p| !p.as_os_str().is_empty())
392        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
393    fs::create_dir_all(&dir).map_err(io(&dir))?;
394    let temp_name = format!(
395        ".{}.restore-{}.tmp",
396        target_path.file_name().map_or_else(|| "database".into(), |n| n.to_string_lossy()),
397        Utc::now().timestamp_nanos_opt().unwrap_or_default()
398    );
399    let temp_path = dir.join(temp_name);
400
401    let copy_result = (|| -> Result<()> {
402        let bytes = fs::read(backup_path).map_err(io(backup_path))?;
403        write_file_durably(&temp_path, &bytes)
404    })();
405    if let Err(err) = copy_result {
406        let _ = fs::remove_file(&temp_path);
407        return Err(err);
408    }
409
410    if let Err(err) = fs::rename(&temp_path, target_path).map_err(io(target_path)) {
411        let _ = fs::remove_file(&temp_path);
412        return Err(err);
413    }
414    // Durably record the directory entry so the rename survives a crash.
415    if let Ok(handle) = File::open(&dir) {
416        let _ = handle.sync_all();
417    }
418
419    // Stale WAL/SHM sidecars from the replaced database would be replayed on
420    // top of the restored file and silently corrupt it.
421    for suffix in ["-wal", "-shm"] {
422        let mut sidecar = target_path.as_os_str().to_os_string();
423        sidecar.push(suffix);
424        let _ = fs::remove_file(PathBuf::from(sidecar));
425    }
426
427    Ok(RestoreReport {
428        target_path: target_path.to_path_buf(),
429        schema_version,
430        size_bytes: backup_size,
431        checksum_verified,
432        replaced_existing,
433    })
434}
435
436/// Write `bytes` to `path`, fsyncing the file before returning.
437fn write_file_durably(path: &Path, bytes: &[u8]) -> Result<()> {
438    let mut file = File::create(path).map_err(io(path))?;
439    file.write_all(bytes).map_err(io(path))?;
440    file.sync_all().map_err(io(path))?;
441    Ok(())
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::migrations;
448
449    fn seeded_db() -> Connection {
450        let mut conn = Connection::open_in_memory().expect("open memory db");
451        migrations::run_migrations(&mut conn).expect("migrate");
452        conn
453    }
454
455    #[test]
456    fn backup_writes_manifest_with_verified_checksum() {
457        let dir = tempfile::tempdir().expect("tempdir");
458        let conn = seeded_db();
459        let backup = dir.path().join("backup.db");
460
461        let report = backup_to(&conn, "memory", &backup).expect("backup");
462
463        assert!(backup.exists());
464        assert!(report.manifest_path.exists());
465        assert_eq!(report.manifest.manifest_version, MANIFEST_VERSION);
466        assert_eq!(report.manifest.engine_version, env!("CARGO_PKG_VERSION"));
467        assert_eq!(report.manifest.schema_version, migrations::latest_known_migration());
468        // Not every migration applies in every build (e.g. `027_vector_search`
469        // is skipped when the sqlite-vec extension is unavailable), so assert a
470        // bound rather than exact equality.
471        assert!(report.manifest.migration_count > 0);
472        assert!(report.manifest.migration_count <= migrations::known_migration_names().len());
473        assert_eq!(report.manifest.checksum, file_checksum(&backup).expect("checksum"));
474        assert!(report.manifest.size_bytes > 0);
475    }
476
477    #[test]
478    fn restore_rejects_corrupted_backup() {
479        let dir = tempfile::tempdir().expect("tempdir");
480        let conn = seeded_db();
481        let backup = dir.path().join("backup.db");
482        backup_to(&conn, "memory", &backup).expect("backup");
483
484        // Flip a byte well past the header so the manifest no longer matches.
485        let mut bytes = fs::read(&backup).expect("read");
486        let idx = bytes.len() / 2;
487        bytes[idx] ^= 0xFF;
488        fs::write(&backup, &bytes).expect("write");
489
490        let err = restore_from(&backup, dir.path().join("restored.db"), &RestoreOptions::default())
491            .expect_err("should refuse corrupted backup");
492        assert!(matches!(err, MaintenanceError::ChecksumMismatch { .. }), "got {err:?}");
493    }
494
495    #[test]
496    fn restore_refuses_backup_from_newer_engine() {
497        let dir = tempfile::tempdir().expect("tempdir");
498        let conn = seeded_db();
499        let backup = dir.path().join("backup.db");
500        backup_to(&conn, "memory", &backup).expect("backup");
501
502        // Rewrite the manifest claiming a migration far beyond what we know.
503        let manifest_path = manifest_path_for(&backup);
504        let mut manifest = read_manifest(&backup).expect("manifest");
505        manifest.schema_version = "999_from_the_future".to_owned();
506        fs::write(&manifest_path, serde_json::to_vec(&manifest).expect("encode")).expect("write");
507
508        let target = dir.path().join("restored.db");
509        let err = restore_from(&backup, &target, &RestoreOptions::default())
510            .expect_err("should refuse newer schema");
511        assert!(matches!(err, MaintenanceError::SchemaTooNew { .. }), "got {err:?}");
512        assert!(!target.exists(), "target must not be created on refusal");
513
514        // Break-glass override still works.
515        let opts = RestoreOptions { allow_newer_schema: true, ..Default::default() };
516        restore_from(&backup, &target, &opts).expect("override restores");
517        assert!(target.exists());
518    }
519
520    #[test]
521    fn restore_refuses_to_clobber_existing_target() {
522        let dir = tempfile::tempdir().expect("tempdir");
523        let conn = seeded_db();
524        let backup = dir.path().join("backup.db");
525        backup_to(&conn, "memory", &backup).expect("backup");
526
527        let target = dir.path().join("live.db");
528        fs::write(&target, b"precious existing data").expect("write");
529
530        let err = restore_from(&backup, &target, &RestoreOptions::default())
531            .expect_err("should refuse to overwrite");
532        assert!(matches!(err, MaintenanceError::TargetExists { .. }), "got {err:?}");
533        assert_eq!(fs::read(&target).expect("read"), b"precious existing data");
534
535        let opts = RestoreOptions { overwrite: true, ..Default::default() };
536        let report = restore_from(&backup, &target, &opts).expect("overwrite restores");
537        assert!(report.replaced_existing);
538        assert!(report.checksum_verified);
539        assert_ne!(fs::read(&target).expect("read"), b"precious existing data");
540    }
541
542    #[test]
543    fn restore_into_empty_placeholder_is_allowed() {
544        let dir = tempfile::tempdir().expect("tempdir");
545        let conn = seeded_db();
546        let backup = dir.path().join("backup.db");
547        backup_to(&conn, "memory", &backup).expect("backup");
548
549        let target = dir.path().join("fresh.db");
550        fs::write(&target, b"").expect("touch");
551        let report =
552            restore_from(&backup, &target, &RestoreOptions::default()).expect("restore into empty");
553        assert!(!report.replaced_existing);
554    }
555
556    #[test]
557    fn restore_leaves_no_temp_files_and_is_openable() {
558        let dir = tempfile::tempdir().expect("tempdir");
559        let conn = seeded_db();
560        conn.execute("INSERT INTO customers (id, email, first_name, last_name) VALUES ('11111111-1111-1111-1111-111111111111', 'a@b.co', 'A', 'B')", [])
561            .ok();
562        let backup = dir.path().join("backup.db");
563        backup_to(&conn, "memory", &backup).expect("backup");
564
565        let target = dir.path().join("restored.db");
566        let report = restore_from(&backup, &target, &RestoreOptions::default()).expect("restore");
567        assert_eq!(report.schema_version, migrations::latest_known_migration());
568        assert!(report.checksum_verified);
569        assert_eq!(report.size_bytes, fs::metadata(&target).expect("meta").len());
570
571        // No `.restore-*.tmp` residue.
572        let leftovers: Vec<_> = fs::read_dir(dir.path())
573            .expect("read dir")
574            .filter_map(std::result::Result::ok)
575            .map(|e| e.file_name().to_string_lossy().into_owned())
576            .filter(|n| n.contains(".restore-"))
577            .collect();
578        assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
579
580        // The restored file is a valid, migrated SQLite database.
581        let restored = Connection::open(&target).expect("open restored");
582        let (version, count) = applied_schema(&restored).expect("schema");
583        assert_eq!(version, migrations::latest_known_migration());
584        assert!(count > 0 && count <= migrations::known_migration_names().len());
585    }
586
587    #[test]
588    fn restore_missing_manifest_is_an_error() {
589        let dir = tempfile::tempdir().expect("tempdir");
590        let conn = seeded_db();
591        let backup = dir.path().join("backup.db");
592        backup_to(&conn, "memory", &backup).expect("backup");
593        fs::remove_file(manifest_path_for(&backup)).expect("remove manifest");
594
595        let err = restore_from(&backup, dir.path().join("t.db"), &RestoreOptions::default())
596            .expect_err("no manifest");
597        assert!(matches!(err, MaintenanceError::Io { .. }), "got {err:?}");
598    }
599
600    #[test]
601    fn schema_comparison_recognises_known_versions() {
602        assert!(!is_schema_newer_than_known(""));
603        assert!(!is_schema_newer_than_known(migrations::latest_known_migration()));
604        assert!(!is_schema_newer_than_known("001_initial_schema"));
605        assert!(is_schema_newer_than_known("999_future"));
606    }
607}