- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
205 lines
7.1 KiB
Rust
205 lines
7.1 KiB
Rust
//! 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::<String>();
|
|
|
|
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<bool> {
|
|
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::<String>();
|
|
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(())
|
|
}
|
|
}
|