use chrono::{DateTime, Utc};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MaintenanceError {
#[error("io error at {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("manifest error: {0}")]
Manifest(String),
#[error("checksum mismatch for {path}: manifest says {expected}, file is {actual}")]
ChecksumMismatch {
path: PathBuf,
expected: String,
actual: String,
},
#[error(
"backup schema version '{backup}' is newer than this engine supports (latest known: '{known}'); \
restore with a build that includes the newer migrations"
)]
SchemaTooNew {
backup: String,
known: String,
},
#[error(
"refusing to overwrite existing database at {path}; pass overwrite = true to replace it"
)]
TargetExists {
path: PathBuf,
},
}
impl From<MaintenanceError> for stateset_core::CommerceError {
fn from(err: MaintenanceError) -> Self {
Self::DatabaseError(err.to_string())
}
}
type Result<T> = std::result::Result<T, MaintenanceError>;
fn io(path: impl Into<PathBuf>) -> impl FnOnce(std::io::Error) -> MaintenanceError {
let path = path.into();
move |source| MaintenanceError::Io { path, source }
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BackupManifest {
pub manifest_version: u32,
pub schema_version: String,
pub migration_count: usize,
pub engine_version: String,
pub created_at: DateTime<Utc>,
pub source_path: String,
pub size_bytes: u64,
pub checksum: String,
}
pub const MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct BackupReport {
pub backup_path: PathBuf,
pub manifest_path: PathBuf,
pub manifest: BackupManifest,
}
#[derive(Debug, Clone, Default)]
pub struct RestoreOptions {
pub overwrite: bool,
pub skip_checksum: bool,
pub allow_newer_schema: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RestoreReport {
pub target_path: PathBuf,
pub schema_version: String,
pub size_bytes: u64,
pub checksum_verified: bool,
pub replaced_existing: bool,
}
#[must_use]
pub fn manifest_path_for(backup_path: &Path) -> PathBuf {
let mut name = backup_path.as_os_str().to_os_string();
name.push(".manifest.json");
PathBuf::from(name)
}
pub fn file_checksum(path: &Path) -> Result<String> {
let mut file = File::open(path).map_err(io(path))?;
let mut hasher = Sha256::new();
let mut buf = vec![0_u8; 64 * 1024];
loop {
let read = file.read(&mut buf).map_err(io(path))?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn applied_schema(conn: &Connection) -> Result<(String, usize)> {
let table_exists: bool = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_migrations'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if !table_exists {
return Ok((String::new(), 0));
}
let count: i64 = conn.query_row("SELECT COUNT(*) FROM _migrations", [], |row| row.get(0))?;
let latest: Option<String> =
conn.query_row("SELECT MAX(name) FROM _migrations", [], |row| row.get(0))?;
Ok((latest.unwrap_or_default(), usize::try_from(count).unwrap_or(0)))
}
pub fn backup_to(
conn: &Connection,
source_path: impl AsRef<Path>,
backup_path: impl AsRef<Path>,
) -> Result<BackupReport> {
let backup_path = backup_path.as_ref();
if let Some(parent) = backup_path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent).map_err(io(parent))?;
}
let literal = backup_path.to_string_lossy().into_owned();
if literal.contains('\0') {
return Err(MaintenanceError::Manifest("backup path contains a NUL byte".to_owned()));
}
conn.execute_batch(&format!("VACUUM INTO '{}';", literal.replace('\'', "''")))?;
let (schema_version, migration_count) = applied_schema(conn)?;
let size_bytes = fs::metadata(backup_path).map_err(io(backup_path))?.len();
let checksum = file_checksum(backup_path)?;
let manifest = BackupManifest {
manifest_version: MANIFEST_VERSION,
schema_version,
migration_count,
engine_version: env!("CARGO_PKG_VERSION").to_owned(),
created_at: Utc::now(),
source_path: source_path.as_ref().to_string_lossy().into_owned(),
size_bytes,
checksum: checksum.clone(),
};
let manifest_path = manifest_path_for(backup_path);
let encoded = serde_json::to_vec_pretty(&manifest)
.map_err(|e| MaintenanceError::Manifest(e.to_string()))?;
write_file_durably(&manifest_path, &encoded)?;
let verified = file_checksum(backup_path)?;
if verified != checksum {
return Err(MaintenanceError::ChecksumMismatch {
path: backup_path.to_path_buf(),
expected: checksum,
actual: verified,
});
}
Ok(BackupReport { backup_path: backup_path.to_path_buf(), manifest_path, manifest })
}
pub fn read_manifest(backup_path: &Path) -> Result<BackupManifest> {
let path = manifest_path_for(backup_path);
let bytes = fs::read(&path).map_err(io(&path))?;
let manifest: BackupManifest =
serde_json::from_slice(&bytes).map_err(|e| MaintenanceError::Manifest(e.to_string()))?;
if manifest.manifest_version > MANIFEST_VERSION {
return Err(MaintenanceError::Manifest(format!(
"manifest version {} is newer than supported version {MANIFEST_VERSION}",
manifest.manifest_version
)));
}
Ok(manifest)
}
fn is_schema_newer_than_known(candidate: &str) -> bool {
if candidate.is_empty() {
return false;
}
let known = crate::migrations::known_migration_names();
if known.contains(&candidate) {
return false;
}
known.last().is_none_or(|latest| candidate > *latest)
}
pub fn restore_from(
backup_path: impl AsRef<Path>,
target_path: impl AsRef<Path>,
options: &RestoreOptions,
) -> Result<RestoreReport> {
let backup_path = backup_path.as_ref();
let target_path = target_path.as_ref();
let backup_size = fs::metadata(backup_path).map_err(io(backup_path))?.len();
let mut schema_version = String::new();
let mut checksum_verified = false;
if options.skip_checksum {
if let Ok(manifest) = read_manifest(backup_path) {
schema_version = manifest.schema_version;
}
} else {
let manifest = read_manifest(backup_path)?;
let actual = file_checksum(backup_path)?;
if actual != manifest.checksum {
return Err(MaintenanceError::ChecksumMismatch {
path: backup_path.to_path_buf(),
expected: manifest.checksum,
actual,
});
}
checksum_verified = true;
schema_version = manifest.schema_version;
}
if !options.allow_newer_schema && is_schema_newer_than_known(&schema_version) {
return Err(MaintenanceError::SchemaTooNew {
backup: schema_version,
known: crate::migrations::latest_known_migration().to_owned(),
});
}
let existing_len = fs::metadata(target_path).map(|m| m.len()).ok();
let replaced_existing = matches!(existing_len, Some(len) if len > 0);
if replaced_existing && !options.overwrite {
return Err(MaintenanceError::TargetExists { path: target_path.to_path_buf() });
}
let dir = target_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
fs::create_dir_all(&dir).map_err(io(&dir))?;
let temp_name = format!(
".{}.restore-{}.tmp",
target_path.file_name().map_or_else(|| "database".into(), |n| n.to_string_lossy()),
Utc::now().timestamp_nanos_opt().unwrap_or_default()
);
let temp_path = dir.join(temp_name);
let copy_result = (|| -> Result<()> {
let bytes = fs::read(backup_path).map_err(io(backup_path))?;
write_file_durably(&temp_path, &bytes)
})();
if let Err(err) = copy_result {
let _ = fs::remove_file(&temp_path);
return Err(err);
}
if let Err(err) = fs::rename(&temp_path, target_path).map_err(io(target_path)) {
let _ = fs::remove_file(&temp_path);
return Err(err);
}
if let Ok(handle) = File::open(&dir) {
let _ = handle.sync_all();
}
for suffix in ["-wal", "-shm"] {
let mut sidecar = target_path.as_os_str().to_os_string();
sidecar.push(suffix);
let _ = fs::remove_file(PathBuf::from(sidecar));
}
Ok(RestoreReport {
target_path: target_path.to_path_buf(),
schema_version,
size_bytes: backup_size,
checksum_verified,
replaced_existing,
})
}
fn write_file_durably(path: &Path, bytes: &[u8]) -> Result<()> {
let mut file = File::create(path).map_err(io(path))?;
file.write_all(bytes).map_err(io(path))?;
file.sync_all().map_err(io(path))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::migrations;
fn seeded_db() -> Connection {
let mut conn = Connection::open_in_memory().expect("open memory db");
migrations::run_migrations(&mut conn).expect("migrate");
conn
}
#[test]
fn backup_writes_manifest_with_verified_checksum() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
let backup = dir.path().join("backup.db");
let report = backup_to(&conn, "memory", &backup).expect("backup");
assert!(backup.exists());
assert!(report.manifest_path.exists());
assert_eq!(report.manifest.manifest_version, MANIFEST_VERSION);
assert_eq!(report.manifest.engine_version, env!("CARGO_PKG_VERSION"));
assert_eq!(report.manifest.schema_version, migrations::latest_known_migration());
assert!(report.manifest.migration_count > 0);
assert!(report.manifest.migration_count <= migrations::known_migration_names().len());
assert_eq!(report.manifest.checksum, file_checksum(&backup).expect("checksum"));
assert!(report.manifest.size_bytes > 0);
}
#[test]
fn restore_rejects_corrupted_backup() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
let backup = dir.path().join("backup.db");
backup_to(&conn, "memory", &backup).expect("backup");
let mut bytes = fs::read(&backup).expect("read");
let idx = bytes.len() / 2;
bytes[idx] ^= 0xFF;
fs::write(&backup, &bytes).expect("write");
let err = restore_from(&backup, dir.path().join("restored.db"), &RestoreOptions::default())
.expect_err("should refuse corrupted backup");
assert!(matches!(err, MaintenanceError::ChecksumMismatch { .. }), "got {err:?}");
}
#[test]
fn restore_refuses_backup_from_newer_engine() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
let backup = dir.path().join("backup.db");
backup_to(&conn, "memory", &backup).expect("backup");
let manifest_path = manifest_path_for(&backup);
let mut manifest = read_manifest(&backup).expect("manifest");
manifest.schema_version = "999_from_the_future".to_owned();
fs::write(&manifest_path, serde_json::to_vec(&manifest).expect("encode")).expect("write");
let target = dir.path().join("restored.db");
let err = restore_from(&backup, &target, &RestoreOptions::default())
.expect_err("should refuse newer schema");
assert!(matches!(err, MaintenanceError::SchemaTooNew { .. }), "got {err:?}");
assert!(!target.exists(), "target must not be created on refusal");
let opts = RestoreOptions { allow_newer_schema: true, ..Default::default() };
restore_from(&backup, &target, &opts).expect("override restores");
assert!(target.exists());
}
#[test]
fn restore_refuses_to_clobber_existing_target() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
let backup = dir.path().join("backup.db");
backup_to(&conn, "memory", &backup).expect("backup");
let target = dir.path().join("live.db");
fs::write(&target, b"precious existing data").expect("write");
let err = restore_from(&backup, &target, &RestoreOptions::default())
.expect_err("should refuse to overwrite");
assert!(matches!(err, MaintenanceError::TargetExists { .. }), "got {err:?}");
assert_eq!(fs::read(&target).expect("read"), b"precious existing data");
let opts = RestoreOptions { overwrite: true, ..Default::default() };
let report = restore_from(&backup, &target, &opts).expect("overwrite restores");
assert!(report.replaced_existing);
assert!(report.checksum_verified);
assert_ne!(fs::read(&target).expect("read"), b"precious existing data");
}
#[test]
fn restore_into_empty_placeholder_is_allowed() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
let backup = dir.path().join("backup.db");
backup_to(&conn, "memory", &backup).expect("backup");
let target = dir.path().join("fresh.db");
fs::write(&target, b"").expect("touch");
let report =
restore_from(&backup, &target, &RestoreOptions::default()).expect("restore into empty");
assert!(!report.replaced_existing);
}
#[test]
fn restore_leaves_no_temp_files_and_is_openable() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
conn.execute("INSERT INTO customers (id, email, first_name, last_name) VALUES ('11111111-1111-1111-1111-111111111111', 'a@b.co', 'A', 'B')", [])
.ok();
let backup = dir.path().join("backup.db");
backup_to(&conn, "memory", &backup).expect("backup");
let target = dir.path().join("restored.db");
let report = restore_from(&backup, &target, &RestoreOptions::default()).expect("restore");
assert_eq!(report.schema_version, migrations::latest_known_migration());
assert!(report.checksum_verified);
assert_eq!(report.size_bytes, fs::metadata(&target).expect("meta").len());
let leftovers: Vec<_> = fs::read_dir(dir.path())
.expect("read dir")
.filter_map(std::result::Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".restore-"))
.collect();
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
let restored = Connection::open(&target).expect("open restored");
let (version, count) = applied_schema(&restored).expect("schema");
assert_eq!(version, migrations::latest_known_migration());
assert!(count > 0 && count <= migrations::known_migration_names().len());
}
#[test]
fn restore_missing_manifest_is_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let conn = seeded_db();
let backup = dir.path().join("backup.db");
backup_to(&conn, "memory", &backup).expect("backup");
fs::remove_file(manifest_path_for(&backup)).expect("remove manifest");
let err = restore_from(&backup, dir.path().join("t.db"), &RestoreOptions::default())
.expect_err("no manifest");
assert!(matches!(err, MaintenanceError::Io { .. }), "got {err:?}");
}
#[test]
fn schema_comparison_recognises_known_versions() {
assert!(!is_schema_newer_than_known(""));
assert!(!is_schema_newer_than_known(migrations::latest_known_migration()));
assert!(!is_schema_newer_than_known("001_initial_schema"));
assert!(is_schema_newer_than_known("999_future"));
}
}