1use 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#[derive(Debug, thiserror::Error)]
63#[non_exhaustive]
64pub enum MaintenanceError {
65 #[error("io error at {path}: {source}")]
67 Io {
68 path: PathBuf,
70 source: std::io::Error,
72 },
73 #[error("sqlite error: {0}")]
75 Sqlite(#[from] rusqlite::Error),
76 #[error("manifest error: {0}")]
78 Manifest(String),
79 #[error("checksum mismatch for {path}: manifest says {expected}, file is {actual}")]
81 ChecksumMismatch {
82 path: PathBuf,
84 expected: String,
86 actual: String,
88 },
89 #[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 backup: String,
97 known: String,
99 },
100 #[error(
102 "refusing to overwrite existing database at {path}; pass overwrite = true to replace it"
103 )]
104 TargetExists {
105 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[non_exhaustive]
126pub struct BackupManifest {
127 pub manifest_version: u32,
129 pub schema_version: String,
131 pub migration_count: usize,
133 pub engine_version: String,
135 pub created_at: DateTime<Utc>,
137 pub source_path: String,
139 pub size_bytes: u64,
141 pub checksum: String,
143}
144
145pub const MANIFEST_VERSION: u32 = 1;
147
148#[derive(Debug, Clone)]
150#[non_exhaustive]
151pub struct BackupReport {
152 pub backup_path: PathBuf,
154 pub manifest_path: PathBuf,
156 pub manifest: BackupManifest,
158}
159
160#[derive(Debug, Clone, Default)]
162pub struct RestoreOptions {
163 pub overwrite: bool,
165 pub skip_checksum: bool,
168 pub allow_newer_schema: bool,
171}
172
173#[derive(Debug, Clone)]
175#[non_exhaustive]
176pub struct RestoreReport {
177 pub target_path: PathBuf,
179 pub schema_version: String,
181 pub size_bytes: u64,
183 pub checksum_verified: bool,
185 pub replaced_existing: bool,
187}
188
189#[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
197pub 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
216fn 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
232pub 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 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 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
294pub 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
314fn 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
329pub 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 let mut schema_version = String::new();
351 let mut checksum_verified = false;
352 if options.skip_checksum {
353 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 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 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 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 if let Ok(handle) = File::open(&dir) {
416 let _ = handle.sync_all();
417 }
418
419 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
436fn 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 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 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 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 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 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 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}