use crate::utils::paths::get_home_directory;
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{info, warn};
#[derive(Debug)]
pub struct BackupManager {
backup_dir: PathBuf,
}
impl BackupManager {
pub fn new() -> Result<Self> {
if cfg!(test) {
let temp_backup_dir = std::env::temp_dir().join("geist-supervisor-test-backups");
fs::create_dir_all(&temp_backup_dir)
.context("Failed to create test backup directory")?;
return Ok(Self {
backup_dir: temp_backup_dir,
});
}
let home_dir = get_home_directory();
let backup_dir = PathBuf::from(home_dir).join(".local/share/geist-supervisor/backups");
match fs::create_dir_all(&backup_dir) {
Ok(()) => {
info!("Backup directory ready: {}", backup_dir.display());
}
Err(e) => {
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok();
if is_test_env || e.kind() == std::io::ErrorKind::Unsupported {
warn!(
"Failed to create backup directory: {}, using temp directory",
e
);
let temp_backup_dir = std::env::temp_dir().join("geist-supervisor-backups");
fs::create_dir_all(&temp_backup_dir)
.context("Failed to create temporary backup directory")?;
return Ok(Self {
backup_dir: temp_backup_dir,
});
} else {
return Err(e).context("Failed to create backup directory");
}
}
}
Ok(Self { backup_dir })
}
pub fn with_backup_dir(backup_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&backup_dir).context("Failed to create backup directory")?;
Ok(Self { backup_dir })
}
pub fn create_backup(&self, source_path: &Path) -> Result<PathBuf> {
if !source_path.exists() {
anyhow::bail!("Source binary does not exist: {}", source_path.display());
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let binary_name = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("binary");
let backup_filename = format!("{}.backup.{}", binary_name, timestamp);
let backup_path = self.backup_dir.join(backup_filename);
info!(
"Creating backup: {} -> {}",
source_path.display(),
backup_path.display()
);
fs::copy(source_path, &backup_path).context("Failed to create backup")?;
self.verify_backup_integrity(source_path, &backup_path)?;
info!("Backup created successfully: {}", backup_path.display());
Ok(backup_path)
}
pub fn restore_from_backup(
&self,
backup_path: &Path,
target_path: &Path,
service_manager: &crate::services::ota::ServiceManager,
) -> Result<()> {
if !backup_path.exists() {
anyhow::bail!("Backup file does not exist: {}", backup_path.display());
}
info!(
"Restoring from backup: {} -> {}",
backup_path.display(),
target_path.display()
);
if let Err(e) = service_manager.stop_service() {
warn!("Failed to stop service before restoration: {}", e);
}
let parent = target_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Target path has no parent directory"))?;
if !parent.exists() {
fs::create_dir_all(parent).context("Failed to create target directory")?;
}
let tmp = tempfile::Builder::new()
.prefix(".geist_restore_")
.tempfile_in(parent)
.context("Failed to create temp file for atomic restore")?;
fs::copy(backup_path, tmp.path()).context("Failed to copy backup to temp file")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o755))
.context("Failed to set permissions on restored binary")?;
}
tmp.persist(target_path)
.context("Failed to atomically rename restored binary into place")?;
if let Err(e) = service_manager.start_service() {
warn!("Failed to start service after restoration: {}", e);
}
info!("Restoration completed successfully");
Ok(())
}
pub fn list_backups(&self) -> Result<Vec<String>> {
let mut backups = Vec::new();
if !self.backup_dir.exists() {
return Ok(backups);
}
let entries = fs::read_dir(&self.backup_dir).context("Failed to read backup directory")?;
for entry in entries {
let entry = entry.context("Failed to read directory entry")?;
let filename = entry.file_name();
if let Some(name) = filename.to_str() {
if name.contains(".backup.") {
backups.push(name.to_string());
}
}
}
backups.sort_by(|a, b| {
let extract_timestamp = |s: &str| -> u64 {
if let Some(pos) = s.rfind('.') {
s[pos + 1..].parse().unwrap_or(0)
} else {
0
}
};
let timestamp_b = extract_timestamp(b);
let timestamp_a = extract_timestamp(a);
timestamp_b.cmp(×tamp_a)
});
Ok(backups)
}
pub fn get_backup_path(&self, backup_name: &str) -> Result<PathBuf> {
Ok(self.backup_dir.join(backup_name))
}
pub fn backup_dir(&self) -> &PathBuf {
&self.backup_dir
}
pub fn backup_exists(&self, backup_name: &str) -> bool {
self.backup_dir.join(backup_name).exists()
}
pub fn cleanup_old_backups(&self, keep_count: usize) -> Result<()> {
let backups = self.list_backups()?;
if backups.len() <= keep_count {
info!(
"No old backups to clean up ({} backups, keeping {})",
backups.len(),
keep_count
);
return Ok(());
}
let backups_to_remove = &backups[keep_count..];
for backup_name in backups_to_remove {
let backup_path = self.backup_dir.join(backup_name);
match fs::remove_file(&backup_path) {
Ok(()) => {
info!("Removed old backup: {}", backup_name);
}
Err(e) => {
warn!("Failed to remove old backup {}: {}", backup_name, e);
}
}
}
info!(
"Cleanup completed. Kept {} backups, removed {} old backups",
keep_count,
backups_to_remove.len()
);
Ok(())
}
fn verify_backup_integrity(&self, original: &Path, backup: &Path) -> Result<()> {
let original_metadata =
fs::metadata(original).context("Failed to get original file metadata")?;
let backup_metadata = fs::metadata(backup).context("Failed to get backup file metadata")?;
if original_metadata.len() != backup_metadata.len() {
anyhow::bail!(
"Backup integrity check failed: size mismatch (original: {} bytes, backup: {} bytes)",
original_metadata.len(),
backup_metadata.len()
);
}
info!("Backup integrity verified: {} bytes", backup_metadata.len());
Ok(())
}
}
impl Default for BackupManager {
fn default() -> Self {
Self::new().unwrap_or_else(|_| {
warn!("Failed to create backup manager with default settings");
Self {
backup_dir: PathBuf::from("/tmp/geist-supervisor-backups"),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::tempdir;
#[test]
fn test_backup_manager_creation() {
let manager = BackupManager::new();
assert!(manager.is_ok());
}
#[test]
fn test_create_backup_success() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let binary_path = temp_dir.path().join("test_binary");
let binary_content = b"test binary content";
fs::write(&binary_path, binary_content).unwrap();
let result = manager.create_backup(&binary_path);
assert!(result.is_ok());
let backup_path = result.unwrap();
assert!(backup_path.exists());
let backup_content = fs::read(&backup_path).unwrap();
assert_eq!(backup_content, binary_content);
}
#[test]
fn test_create_backup_nonexistent_binary() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let nonexistent_path = temp_dir.path().join("nonexistent_binary");
let result = manager.create_backup(&nonexistent_path);
assert!(result.is_err());
}
#[test]
fn test_restore_from_backup_success() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let backup_content = b"backup binary content";
let backup_path = temp_dir.path().join("test_backup");
fs::write(&backup_path, backup_content).unwrap();
let target_path = temp_dir.path().join("target_binary");
fs::write(&target_path, b"original content").unwrap();
let result = manager.restore_from_backup(
&backup_path,
&target_path,
&crate::services::ota::ServiceManager::new("test_service".to_string()),
);
assert!(result.is_ok());
let restored_content = fs::read(&target_path).unwrap();
assert_eq!(restored_content, backup_content);
}
#[test]
fn test_restore_from_backup_missing_backup() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let nonexistent_backup = temp_dir.path().join("nonexistent_backup");
let target_path = temp_dir.path().join("target_binary");
let result = manager.restore_from_backup(
&nonexistent_backup,
&target_path,
&crate::services::ota::ServiceManager::new("test_service".to_string()),
);
assert!(result.is_err());
}
#[test]
fn test_list_backups() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let backup_files = ["test.backup.v1.0.0.123", "test.backup.v1.1.0.456"];
for backup_file in &backup_files {
let backup_path = manager.backup_dir().join(backup_file);
File::create(backup_path).unwrap();
}
let backups = manager.list_backups().unwrap();
assert_eq!(backups.len(), 2);
assert!(backups.contains(&backup_files[0].to_string()));
assert!(backups.contains(&backup_files[1].to_string()));
}
#[test]
fn test_cleanup_old_backups() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let backup_files = [
"test.backup.v1.0.0.100",
"test.backup.v1.1.0.200",
"test.backup.v1.2.0.300",
];
for backup_file in &backup_files {
let backup_path = manager.backup_dir().join(backup_file);
File::create(backup_path).unwrap();
}
let result = manager.cleanup_old_backups(2);
assert!(result.is_ok());
let remaining_backups = manager.list_backups().unwrap();
assert_eq!(remaining_backups.len(), 2);
}
#[test]
fn test_backup_exists() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir).unwrap();
let backup_name = "test.backup.v1.0.0.123";
assert!(!manager.backup_exists(backup_name));
let backup_path = manager.backup_dir().join(backup_name);
File::create(backup_path).unwrap();
assert!(manager.backup_exists(backup_name));
}
#[test]
fn test_get_backup_path() {
let temp_dir = tempdir().unwrap();
let backup_dir = temp_dir.path().join("backups");
let manager = BackupManager::with_backup_dir(backup_dir.clone()).unwrap();
let backup_name = "test.backup.v1.0.0.123";
let backup_path = manager.get_backup_path(backup_name).unwrap();
assert_eq!(backup_path, backup_dir.join(backup_name));
}
}