//! Backup and Restore engine for consistent SQLite snapshots and manifests. use crate::error::{ApiError, ApiResult}; use chrono::Utc; use nx9_wg_core::types::audit::AuditEventType; use nx9_wg_core::types::backup::{BackupFileEntry, BackupManifest, BackupMeta}; use nx9_wg_db::Store; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use uuid::Uuid; /// Backup and restore management service. pub struct BackupService; impl BackupService { /// Create a consistent, atomic SQLite snapshot backup and manifest. pub async fn create_backup( store: &Store, backup_dir: &Path, description: Option<&str>, actor: &str, ip_address: Option<&str>, ) -> ApiResult<(BackupMeta, PathBuf)> { if !backup_dir.exists() { std::fs::create_dir_all(backup_dir).map_err(|e| { ApiError::Internal(format!("Failed to create backup directory: {e}")) })?; } let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); let filename = format!("nx9-backup-{timestamp}.db"); let backup_path = backup_dir.join(&filename); let backup_path_str = backup_path.to_string_lossy().to_string(); // 1. Perform atomic SQLite VACUUM INTO store.vacuum_into(&backup_path_str).await?; // 2. Read bytes to compute checksum and size let bytes = std::fs::read(&backup_path) .map_err(|e| ApiError::Internal(format!("Failed to read created backup file: {e}")))?; let size_bytes = bytes.len() as i64; let hash_bytes = Sha256::digest(&bytes); let checksum = hash_bytes .iter() .map(|b| format!("{b:02x}")) .collect::(); let now = Utc::now().naive_utc(); let backup_id = Uuid::new_v4(); let meta = BackupMeta { id: backup_id, filename: filename.clone(), size_bytes, checksum: checksum.clone(), schema_version: "1".to_string(), encrypted: false, description: description.map(|s| s.to_string()), created_at: now, }; // 3. Write manifest file let manifest = BackupManifest { version: env!("CARGO_PKG_VERSION").to_string(), schema_version: "1".to_string(), created_at: now, checksum: checksum.clone(), encrypted: false, files: vec![BackupFileEntry { path: filename.clone(), size_bytes: size_bytes as u64, checksum: checksum.clone(), }], notes: description.map(|s| s.to_string()), }; let manifest_path = backup_dir.join(format!("nx9-backup-{timestamp}.manifest.json")); let manifest_json = serde_json::to_string_pretty(&manifest) .map_err(|e| ApiError::Internal(format!("Failed to serialize backup manifest: {e}")))?; std::fs::write(&manifest_path, manifest_json) .map_err(|e| ApiError::Internal(format!("Failed to write backup manifest: {e}")))?; // 4. Save metadata in SQLite store.create_backup_meta(&meta).await?; // 5. Audit event let _ = store .record_audit( AuditEventType::BackupCreate, actor, Some("backup"), Some(&backup_id.to_string()), Some(&format!("Created backup '{filename}' ({size_bytes} bytes)")), None, ip_address, ) .await; Ok((meta, backup_path)) } /// Verify the integrity and SQLite magic header of a backup file. pub fn verify_backup(backup_file: &Path, expected_checksum: Option<&str>) -> ApiResult { if !backup_file.exists() { return Err(ApiError::NotFound(format!( "Backup file '{}' not found", backup_file.display() ))); } let bytes = std::fs::read(backup_file).map_err(|e| { ApiError::Internal(format!("Failed to read backup file for verification: {e}")) })?; if bytes.len() < 100 { return Ok(false); } // Verify SQLite 3 header magic if &bytes[0..16] != b"SQLite format 3\0" { return Ok(false); } // Verify checksum if supplied if let Some(expected) = expected_checksum { let hash_bytes = Sha256::digest(&bytes); let calculated = hash_bytes .iter() .map(|b| format!("{b:02x}")) .collect::(); if calculated.to_lowercase() != expected.to_lowercase() { return Ok(false); } } Ok(true) } /// Restore database from a verified backup file with safety pre-restore backup snapshot. pub async fn restore_backup( store: &Store, backup_file: &Path, active_db_path: &Path, safety_dir: &Path, actor: &str, ip_address: Option<&str>, ) -> ApiResult<()> { // 1. Verify backup file before touching active DB let is_valid = Self::verify_backup(backup_file, None)?; if !is_valid { return Err(ApiError::Validation( "Backup file failed verification: invalid SQLite format or corrupted data" .to_string(), )); } // 2. Create pre-restore safety snapshot of the active database if active_db_path.exists() { if !safety_dir.exists() { let _ = std::fs::create_dir_all(safety_dir); } let safety_name = format!( "pre-restore-safety-{}.bak", Utc::now().format("%Y%m%d-%H%M%S") ); let safety_path = safety_dir.join(safety_name); let _ = store.vacuum_into(&safety_path.to_string_lossy()).await; } // 3. Record audit event before closing pool let _ = store .record_audit( AuditEventType::BackupRestore, actor, Some("backup"), None, Some(&format!( "Database restore initiated from '{}'", backup_file.display() )), None, ip_address, ) .await; // 4. Close store pool to release file locks store.close().await; // 5. Clean up existing active database and WAL, SHM, and journal files let wal_path = PathBuf::from(format!("{}-wal", active_db_path.display())); let shm_path = PathBuf::from(format!("{}-shm", active_db_path.display())); let journal_path = PathBuf::from(format!("{}-journal", active_db_path.display())); let _ = std::fs::remove_file(wal_path); let _ = std::fs::remove_file(shm_path); let _ = std::fs::remove_file(journal_path); let _ = std::fs::remove_file(active_db_path); // 6. Copy backup file to active database location std::fs::copy(backup_file, active_db_path) .map_err(|e| ApiError::Internal(format!("Failed to restore database file: {e}")))?; Ok(()) } }