use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{error, info, warn};
use crate::utils::paths::get_home_directory;
pub mod backup_manager;
pub mod service_manager;
pub use backup_manager::BackupManager;
pub use service_manager::ServiceManager;
const DEFAULT_BACKUP_RETENTION_COUNT: usize = 5;
#[derive(Debug)]
pub struct AtomicOpsManager {
app_binary_path: PathBuf,
}
impl AtomicOpsManager {
pub fn new(app_binary_path: PathBuf) -> Self {
Self { app_binary_path }
}
pub fn app_binary_path(&self) -> &Path {
&self.app_binary_path
}
pub fn perform_atomic_update(
&self,
new_binary_path: &Path,
service_manager: &ServiceManager,
_verification_manager: &VerificationManager,
) -> Result<()> {
info!("Performing atomic update");
service_manager.stop_service()?;
self.atomic_binary_replacement(new_binary_path)?;
self.set_binary_permissions()?;
if let Err(e) = self.update_system_assets() {
warn!("Failed to update system assets: {}", e);
}
service_manager.start_service()?;
service_manager.wait_for_service_active(None)?;
info!("Atomic update completed successfully");
Ok(())
}
fn atomic_binary_replacement(&self, new_binary_path: &Path) -> Result<()> {
info!("Performing atomic binary replacement");
let parent = self
.app_binary_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Binary path has no parent directory"))?;
if !parent.exists() {
fs::create_dir_all(parent).context("Failed to create parent directory for binary")?;
}
let tmp = tempfile::Builder::new()
.prefix(".geist_update_")
.tempfile_in(parent)
.context("Failed to create temp file for atomic replacement")?;
fs::copy(new_binary_path, tmp.path()).context("Failed to write new binary 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 temp binary")?;
}
tmp.persist(&self.app_binary_path)
.context("Failed to atomically rename new binary into place")?;
info!("Binary replacement completed (atomic rename)");
Ok(())
}
fn set_binary_permissions(&self) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&self.app_binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&self.app_binary_path, perms)
.context("Failed to set binary permissions")?;
info!("Binary permissions set to 755");
}
#[cfg(not(unix))]
{
info!("Skipping permission setting on non-Unix system");
}
Ok(())
}
pub fn execute_install_script(
&self,
install_script_path: &Path,
release_bundle_dir: &Path,
signature_verified: bool,
) -> Result<()> {
info!(
"Executing install script: {}",
install_script_path.display()
);
let current_user = self.get_current_user()?;
info!("Installing with user: {}", current_user);
if signature_verified {
let marker_path = release_bundle_dir.join(".bundle_sig_verified");
self.create_signature_marker(&marker_path)?;
}
self.unlink_own_binary();
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
let mut command = if is_test_env {
info!("Test environment detected, running install script without sudo");
let mut cmd = Command::new("bash");
cmd.arg(install_script_path).arg(¤t_user);
cmd
} else {
let mut cmd = Command::new("sudo");
cmd.arg("bash").arg(install_script_path).arg(¤t_user);
cmd
};
command.current_dir(release_bundle_dir);
command.env_remove("BUNDLE_SIG_VERIFIED");
let output = command
.output()
.context("Failed to execute install script")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
anyhow::bail!(
"Install script failed with exit code {}\nSTDOUT:\n{}\nSTDERR:\n{}",
output.status.code().unwrap_or(-1),
stdout,
stderr
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
info!("Install script completed successfully");
if !stdout.is_empty() {
info!("Install script output:\n{}", stdout);
}
Ok(())
}
pub fn update_system_assets(&self) -> Result<()> {
Ok(())
}
fn create_signature_marker(&self, marker_path: &Path) -> Result<()> {
info!("Creating signature marker: {}", marker_path.display());
if marker_path.exists() || marker_path.is_symlink() {
let _ = fs::remove_file(marker_path);
}
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
fs::write(marker_path, "").context("Failed to create signature marker in test mode")?;
} else {
let output = Command::new("sudo")
.args([
"install",
"-m",
"400",
"/dev/null",
&marker_path.to_string_lossy(),
])
.output()
.context("Failed to run sudo install for signature marker")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to create signature marker: {}", stderr);
}
}
info!("Signature marker created successfully");
Ok(())
}
fn unlink_own_binary(&self) {
if let Ok(exe) = std::env::current_exe() {
if let Ok(resolved) = exe.canonicalize() {
info!(
"Unlinking own binary to avoid ETXTBSY: {}",
resolved.display()
);
if let Err(e) = fs::remove_file(&resolved) {
warn!(
"Could not unlink own binary (install.sh may still work): {}",
e
);
}
}
}
}
fn get_current_user(&self) -> Result<String> {
use std::env;
if let Ok(sudo_user) = env::var("SUDO_USER") {
if !sudo_user.is_empty() && sudo_user != "root" {
return Ok(sudo_user);
}
}
if let Ok(user) = env::var("USER") {
if !user.is_empty() && user != "root" {
return Ok(user);
}
}
if let Ok(logname) = env::var("LOGNAME") {
if !logname.is_empty() && logname != "root" {
return Ok(logname);
}
}
let output = Command::new("whoami")
.output()
.context("Failed to run whoami command")?;
if output.status.success() {
let username = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in whoami output")?
.trim()
.to_string();
if !username.is_empty() {
return Ok(username);
}
}
anyhow::bail!("Could not determine current user")
}
}
#[derive(Debug)]
pub struct VerificationManager {
app_binary_path: PathBuf,
}
impl VerificationManager {
pub fn new(app_binary_path: PathBuf) -> Self {
Self { app_binary_path }
}
pub fn verify_preconditions(&self, new_binary_path: &Path) -> Result<()> {
info!("Verifying update preconditions");
if !new_binary_path.exists() {
anyhow::bail!("New binary not found: {}", new_binary_path.display());
}
let metadata = fs::metadata(new_binary_path)?;
if metadata.len() == 0 {
anyhow::bail!("New binary is empty: {}", new_binary_path.display());
}
self.verify_binary_format(new_binary_path)?;
self.verify_disk_space(new_binary_path)?;
self.verify_system_requirements()?;
info!("All preconditions verified successfully");
Ok(())
}
pub fn verify_library_dependencies(&self, bundle_dir: &Path) -> Result<()> {
info!("Verifying library dependencies in {}", bundle_dir.display());
info!("Library dependency verification completed");
Ok(())
}
fn verify_disk_space(&self, new_binary_path: &Path) -> Result<()> {
info!("Verifying disk space");
let new_binary_size = fs::metadata(new_binary_path)?.len();
let target_dir = self
.app_binary_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Cannot determine target directory"))?;
#[cfg(unix)]
{
use std::ffi::CString;
use std::mem;
let path_cstr = CString::new(target_dir.to_string_lossy().as_bytes())?;
let mut statvfs_buf: libc::statvfs = unsafe { mem::zeroed() };
let result = unsafe { libc::statvfs(path_cstr.as_ptr(), &mut statvfs_buf) };
if result == 0 {
#[allow(clippy::unnecessary_cast)]
let available_bytes = (statvfs_buf.f_bavail as u64) * (statvfs_buf.f_frsize as u64);
let required_bytes = new_binary_size + (10 * 1024 * 1024);
if available_bytes < required_bytes {
anyhow::bail!(
"Insufficient disk space. Required: {} bytes, Available: {} bytes",
required_bytes,
available_bytes
);
}
info!("Disk space verification passed");
} else {
warn!("Could not check disk space, proceeding anyway");
}
}
#[cfg(not(unix))]
{
warn!("Disk space checking not implemented for this platform");
}
Ok(())
}
fn verify_binary_format(&self, binary_path: &Path) -> Result<()> {
info!("Verifying binary format");
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if is_test_env {
info!("Test environment detected, skipping binary format validation");
return Ok(());
}
#[cfg(unix)]
{
let mut file = fs::File::open(binary_path)?;
use std::io::Read;
let mut header = [0u8; 4];
file.read_exact(&mut header)?;
if header != [0x7f, b'E', b'L', b'F'] {
anyhow::bail!("Binary does not have valid ELF header");
}
}
#[cfg(not(unix))]
{
let metadata = fs::metadata(binary_path)?;
if !metadata.is_file() {
anyhow::bail!("Binary path is not a regular file");
}
}
info!("Binary format verification passed");
Ok(())
}
pub fn verify_system_requirements(&self) -> Result<()> {
info!("Verifying system requirements");
if cfg!(target_os = "linux") {
info!("Running on Linux - supported");
} else if cfg!(target_os = "macos") {
warn!("Running on macOS - limited support");
} else if cfg!(target_os = "windows") {
warn!("Running on Windows - limited support");
}
if cfg!(target_arch = "aarch64") {
info!("Running on ARM64 - supported");
} else if cfg!(target_arch = "x86_64") {
info!("Running on x86_64 - supported");
} else {
warn!("Running on unsupported architecture");
}
#[cfg(target_os = "linux")]
{
match Command::new("systemctl").arg("--version").output() {
Ok(_output) => {
info!("systemd is available");
}
Err(_) => {
warn!("systemd not found - service management may not work");
}
}
}
let required_commands = ["cp", "chmod", "chown"];
for cmd in &required_commands {
match Command::new("which").arg(cmd).output() {
Ok(output) => {
if output.status.success() {
info!("Found required command: {}", cmd);
} else {
warn!("Required command not found: {}", cmd);
}
}
Err(_) => {
warn!("Could not check for command: {}", cmd);
}
}
}
info!("System requirements verification completed");
Ok(())
}
pub fn verify_binary_executable(&self, binary_path: &Path) -> Result<()> {
info!("Verifying binary is executable: {}", binary_path.display());
let force_test_execution = std::env::var("GEIST_FORCE_TEST_EXECUTION").is_ok();
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok()
|| (cfg!(test) && !force_test_execution);
if is_test_env {
info!("Test environment detected, skipping binary execution test");
return Ok(());
}
let test_commands = ["--version", "--help", "-h"];
let mut success = false;
for flag in &test_commands {
match Command::new(binary_path).arg(flag).output() {
Ok(_output) => {
info!("Binary executed successfully with {} flag", flag);
success = true;
break;
}
Err(e) => {
warn!("Failed to execute binary with {} flag: {}", flag, e);
}
}
}
if !success {
match Command::new(binary_path).output() {
Ok(_) => {
info!("Binary is executable");
}
Err(e) => {
anyhow::bail!("Binary is not executable: {}", e);
}
}
}
info!("Binary executable verification completed");
Ok(())
}
}
#[derive(Debug)]
pub struct UninstallManager {
app_binary_path: PathBuf,
}
impl UninstallManager {
pub fn new(app_binary_path: PathBuf) -> Self {
Self { app_binary_path }
}
pub fn uninstall(&self, service_manager: &ServiceManager) -> Result<()> {
info!("Starting complete uninstallation process");
if let Err(e) = service_manager.stop_service() {
warn!(
"Failed to stop service during uninstall (continuing anyway): {}",
e
);
}
self.remove_systemd_service()?;
self.remove_application_files()?;
self.remove_system_integrations()?;
self.cleanup_user_data()?;
self.remove_desktop_entries()?;
self.cleanup_logs()?;
info!("Uninstallation completed successfully");
Ok(())
}
fn remove_systemd_service(&self) -> Result<()> {
info!("Removing systemd service");
let service_name = self.get_service_name();
let service_file = format!("/etc/systemd/system/{}.service", service_name);
if Path::new(&service_file).exists() {
info!("Removing service file: {}", service_file);
let disable_result = Command::new("sudo")
.args(["systemctl", "disable", &service_name])
.output();
if let Ok(output) = disable_result {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to disable service: {}", stderr);
}
}
let remove_result = Command::new("sudo")
.args(["rm", "-f", &service_file])
.output();
if let Ok(output) = remove_result {
if output.status.success() {
info!("Service file removed successfully");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to remove service file: {}", stderr);
}
}
let reload_result = Command::new("sudo")
.args(["systemctl", "daemon-reload"])
.output();
if let Err(e) = reload_result {
warn!("Failed to reload systemd daemon: {}", e);
}
} else {
info!("No systemd service file found to remove");
}
Ok(())
}
fn remove_application_files(&self) -> Result<()> {
info!("Removing application files");
if let Some(app_dir) = self.app_binary_path.parent() {
if app_dir.exists() {
info!("Removing application directory: {}", app_dir.display());
match fs::remove_dir_all(app_dir) {
Ok(()) => {
info!("Application directory removed successfully");
}
Err(e) => {
warn!("Failed to remove application directory: {}", e);
let sudo_result = Command::new("sudo")
.args(["rm", "-rf", &app_dir.to_string_lossy()])
.output();
if let Ok(output) = sudo_result {
if output.status.success() {
info!("Application directory removed with sudo");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
error!(
"Failed to remove application directory even with sudo: {}",
stderr
);
}
}
}
}
} else {
info!("Application directory does not exist");
}
}
let service_name = self.get_service_name();
let additional_dirs = [
format!("/usr/local/bin/{}", service_name),
format!("/opt/{}", service_name),
];
for dir_path in &additional_dirs {
let path = Path::new(dir_path.as_str());
if path.exists() {
info!("Removing additional directory: {}", dir_path);
let result = Command::new("sudo")
.args(["rm", "-rf", dir_path.as_str()])
.output();
if let Ok(output) = result {
if output.status.success() {
info!("Removed: {}", dir_path);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to remove {}: {}", dir_path, stderr);
}
}
}
}
Ok(())
}
fn remove_system_integrations(&self) -> Result<()> {
info!("Removing system integrations");
let service_name = self.get_service_name();
let udev_rules = [
format!("/etc/udev/rules.d/99-{}.rules", service_name),
"/etc/udev/rules.d/99-geist.rules".to_string(),
];
for rule_file in &udev_rules {
let path = Path::new(rule_file.as_str());
if path.exists() {
info!("Removing udev rule: {}", rule_file);
let result = Command::new("sudo")
.args(["rm", "-f", rule_file.as_str()])
.output();
if let Ok(output) = result {
if output.status.success() {
info!("Removed udev rule: {}", rule_file);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to remove udev rule {}: {}", rule_file, stderr);
}
}
}
}
let udev_reload = Command::new("sudo")
.args(["udevadm", "control", "--reload-rules"])
.output();
if let Err(e) = udev_reload {
warn!("Failed to reload udev rules: {}", e);
}
let sudoers_files = [
format!("/etc/sudoers.d/{}", service_name),
"/etc/sudoers.d/geist_supervisor".to_string(),
];
for sudoers_file in &sudoers_files {
let path = Path::new(sudoers_file.as_str());
if path.exists() {
info!("Removing sudoers file: {}", sudoers_file);
let result = Command::new("sudo")
.args(["rm", "-f", sudoers_file.as_str()])
.output();
if let Ok(output) = result {
if output.status.success() {
info!("Removed sudoers file: {}", sudoers_file);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to remove sudoers file {}: {}", sudoers_file, stderr);
}
}
}
}
Ok(())
}
fn cleanup_user_data(&self) -> Result<()> {
let home_dir = get_home_directory();
self.cleanup_user_data_in(Path::new(&home_dir))
}
fn cleanup_user_data_in(&self, home_dir: &Path) -> Result<()> {
let should_remove = std::env::var("GEIST_UNINSTALL_REMOVE_USER_DATA")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
self.cleanup_user_data_impl(home_dir, should_remove)
}
fn cleanup_user_data_impl(&self, home_dir: &Path, should_remove: bool) -> Result<()> {
info!("Cleaning up user data");
let service_name = self.get_service_name();
let user_data_dirs = [
home_dir.join(".local/share/geist-supervisor"),
home_dir.join(format!(".config/{}", service_name)),
home_dir.join(format!(".cache/{}", service_name)),
];
for data_dir in &user_data_dirs {
if data_dir.exists() {
info!("Found user data directory: {}", data_dir.display());
if should_remove {
info!("Removing user data directory: {}", data_dir.display());
match fs::remove_dir_all(data_dir) {
Ok(()) => {
info!("User data directory removed: {}", data_dir.display());
}
Err(e) => {
warn!(
"Failed to remove user data directory {}: {}",
data_dir.display(),
e
);
}
}
} else {
info!("Skipping user data directory: {} (set GEIST_UNINSTALL_REMOVE_USER_DATA=true to remove)", data_dir.display());
}
}
}
Ok(())
}
fn remove_desktop_entries(&self) -> Result<()> {
info!("Removing desktop entries");
let service_name = self.get_service_name();
let desktop_files_owned = [
format!("/usr/share/applications/{}.desktop", service_name),
format!("/usr/local/share/applications/{}.desktop", service_name),
];
let home_dir = get_home_directory();
let user_desktop_files = [format!(
"{}/.local/share/applications/{}.desktop",
home_dir, service_name
)];
let all_desktop_files = desktop_files_owned
.iter()
.map(|s| s.as_str())
.chain(user_desktop_files.iter().map(|s| s.as_str()));
for desktop_file in all_desktop_files {
let path = Path::new(desktop_file);
if path.exists() {
info!("Removing desktop file: {}", desktop_file);
let result = if desktop_file.starts_with(&home_dir) {
fs::remove_file(path).map_err(|e| e.into())
} else {
Command::new("sudo")
.args(["rm", "-f", desktop_file])
.output()
.map(|output| {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Command failed: {}", stderr);
}
Ok(())
})?
};
match result {
Ok(()) => {
info!("Removed desktop file: {}", desktop_file);
}
Err(e) => {
warn!("Failed to remove desktop file {}: {}", desktop_file, e);
}
}
}
}
let update_result = Command::new("update-desktop-database").output();
if let Ok(output) = update_result {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to update desktop database: {}", stderr);
}
}
Ok(())
}
fn cleanup_logs(&self) -> Result<()> {
info!("Cleaning up log files");
let service_name = self.get_service_name();
let log_locations = [
format!("/var/log/{}", service_name),
"/var/log/geist_supervisor".to_string(),
format!("/tmp/{}.log", service_name),
"/tmp/geist_supervisor.log".to_string(),
];
for log_location in &log_locations {
let path = Path::new(log_location.as_str());
if path.exists() {
info!("Removing log location: {}", log_location);
let result = Command::new("sudo")
.args(["rm", "-rf", log_location.as_str()])
.output();
if let Ok(output) = result {
if output.status.success() {
info!("Removed logs: {}", log_location);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to remove logs {}: {}", log_location, stderr);
}
}
}
}
info!(
"Cleaning up systemd journal logs for service: {}",
service_name
);
let journal_result = Command::new("sudo")
.args(["journalctl", "--vacuum-time=1s", "--unit", &service_name])
.output();
if let Ok(output) = journal_result {
if output.status.success() {
info!("Cleaned up systemd journal logs");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to clean systemd journal logs: {}", stderr);
}
}
Ok(())
}
fn get_service_name(&self) -> String {
if let Some(filename) = self.app_binary_path.file_name() {
filename.to_string_lossy().to_string()
} else {
"geist_supervisor".to_string() }
}
pub fn verify_uninstallation(&self) -> Result<()> {
info!("Verifying uninstallation was successful");
let mut issues = Vec::new();
if self.app_binary_path.exists() {
issues.push(format!(
"Binary still exists: {}",
self.app_binary_path.display()
));
}
let is_test_env = std::env::var("GEIST_APP_BINARY_PATH_TEST").is_ok() || cfg!(test);
if !is_test_env {
let service_name = self.get_service_name();
if let Ok(output) = Command::new("sudo")
.args(["systemctl", "is-active", &service_name])
.output()
{
if output.status.success() {
let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
if status == "active" {
issues.push(format!("Service {} is still active", service_name));
}
}
}
let service_file = format!("/etc/systemd/system/{}.service", service_name);
if Path::new(&service_file).exists() {
issues.push(format!("Service file still exists: {}", service_file));
}
} else {
info!("Test environment detected, skipping service verification");
}
if issues.is_empty() {
info!("Uninstallation verification passed - all components removed");
Ok(())
} else {
warn!("Uninstallation verification found issues:");
for issue in &issues {
warn!(" - {}", issue);
}
anyhow::bail!(
"Uninstallation verification failed: {} issues found",
issues.len()
);
}
}
pub fn backup_configuration(&self) -> Result<PathBuf> {
self.backup_configuration_to(get_home_directory())
}
fn backup_configuration_to(&self, home_dir: String) -> Result<PathBuf> {
info!("Creating configuration backup before uninstall");
let backup_dir =
PathBuf::from(&home_dir).join(".local/share/geist-supervisor/uninstall-backups");
fs::create_dir_all(&backup_dir).context("Failed to create backup directory")?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
let backup_file = backup_dir.join(format!("config-backup-{}.tar.gz", timestamp));
let service_name = self.get_service_name();
let config_dirs = [
format!("{}/.config/{}", home_dir, service_name),
format!("/etc/{}", service_name),
];
let mut tar_args = vec!["czf".to_string(), backup_file.to_string_lossy().to_string()];
for config_dir in &config_dirs {
if Path::new(config_dir).exists() {
tar_args.push(config_dir.clone());
}
}
if tar_args.len() > 2 {
let result = Command::new("tar").args(&tar_args).output();
match result {
Ok(output) => {
if output.status.success() {
info!("Configuration backup created: {}", backup_file.display());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to create configuration backup: {}", stderr);
}
}
Err(e) => {
warn!("Failed to run tar for configuration backup: {}", e);
}
}
} else {
info!("No configuration directories found to backup");
}
Ok(backup_file)
}
}
#[derive(Debug)]
pub struct OtaCoordinator {
app_binary_path: PathBuf,
service_manager: ServiceManager,
verification_manager: VerificationManager,
atomic_ops_manager: AtomicOpsManager,
backup_manager: BackupManager,
uninstall_manager: UninstallManager,
binary_names: Vec<String>,
health_check_url: Option<String>,
health_check_timeout_secs: u32,
health_check_retries: u32,
}
impl OtaCoordinator {
pub fn new(app_binary_path: PathBuf, service_name: String) -> Self {
Self::with_binary_names(
app_binary_path,
service_name.clone(),
vec![service_name, "geist_supervisor".to_string()],
)
}
pub fn with_binary_names(
app_binary_path: PathBuf,
service_name: String,
binary_names: Vec<String>,
) -> Self {
let service_manager = ServiceManager::new(service_name.clone());
let verification_manager = VerificationManager::new(app_binary_path.clone());
let atomic_ops_manager = AtomicOpsManager::new(app_binary_path.clone());
let backup_manager = BackupManager::new().unwrap_or_else(|e| {
warn!("Failed to initialize backup manager: {}, using fallback", e);
BackupManager::default()
});
let uninstall_manager = UninstallManager::new(app_binary_path.clone());
Self {
app_binary_path,
service_manager,
verification_manager,
atomic_ops_manager,
backup_manager,
uninstall_manager,
binary_names,
health_check_url: None,
health_check_timeout_secs: 30,
health_check_retries: 6,
}
}
pub fn with_health_check(
mut self,
url: Option<String>,
timeout_secs: u32,
retries: u32,
) -> Self {
self.health_check_url = url;
self.health_check_timeout_secs = timeout_secs;
self.health_check_retries = retries;
self
}
pub fn perform_update(&self, new_binary_path: &Path) -> Result<()> {
info!("Starting OTA update process");
self.verification_manager
.verify_preconditions(new_binary_path)?;
let backup_path = match self.backup_manager.create_backup(&self.app_binary_path) {
Ok(path) => Some(path),
Err(e) => {
warn!("Failed to create backup: {}, continuing without backup", e);
None
}
};
if let Err(e) = self.atomic_ops_manager.perform_atomic_update(
new_binary_path,
&self.service_manager,
&self.verification_manager,
) {
if let Some(ref backup_path) = backup_path {
warn!("Update failed, attempting rollback: {}", e);
if let Err(rollback_err) = self.restore_from_backup(backup_path) {
warn!("Rollback also failed: {}", rollback_err);
}
}
return Err(e);
}
if let Some(ref url) = self.health_check_url {
info!("Running post-update health check at {}", url);
let total_timeout =
std::time::Duration::from_secs(self.health_check_timeout_secs as u64);
let per_retry_delay = total_timeout / self.health_check_retries.max(1);
let health = crate::services::HealthCheckService::new(
url.clone(),
self.health_check_retries,
per_retry_delay,
);
if let Err(e) = health.check_health() {
warn!("Health check failed after update: {}", e);
if let Some(ref backup_path) = backup_path {
warn!("Rolling back due to failed health check");
if let Err(rollback_err) = self.restore_from_backup(backup_path) {
warn!("Rollback also failed: {}", rollback_err);
}
}
return Err(e.context("Post-update health check failed, rolled back"));
}
info!("Post-update health check passed");
}
if let Err(e) = self
.backup_manager
.cleanup_old_backups(DEFAULT_BACKUP_RETENTION_COUNT)
{
warn!("Failed to cleanup old backups: {}", e);
}
info!("OTA update completed successfully");
Ok(())
}
pub fn perform_binary_update_with_assets(
&self,
new_binary_path: &Path,
release_bundle_dir: &Path,
) -> Result<()> {
info!("Starting binary update with asset copying");
if let Err(e) = self.copy_assets_from_bundle(release_bundle_dir) {
warn!("Failed to copy assets from bundle: {}", e);
}
self.perform_update(new_binary_path)
}
fn restore_from_backup(&self, backup_path: &Path) -> Result<()> {
info!("Restoring from backup: {}", backup_path.display());
self.backup_manager.restore_from_backup(
backup_path,
&self.app_binary_path,
&self.service_manager,
)
}
pub fn get_service_status(&self) -> bool {
self.service_manager.is_service_running()
}
pub fn get_service_status_readonly(&self) -> String {
self.service_manager.check_service_status_readonly()
}
pub fn uninstall(&self) -> Result<()> {
self.uninstall_manager.uninstall(&self.service_manager)
}
pub fn list_backups(&self) -> Result<Vec<String>> {
self.backup_manager.list_backups()
}
pub fn rollback_to_backup(&self, backup_name: &str) -> Result<()> {
let backup_path = self.backup_manager.get_backup_path(backup_name)?;
self.restore_from_backup(&backup_path)
}
pub fn cleanup_backups(&self) -> Result<()> {
self.backup_manager
.cleanup_old_backups(DEFAULT_BACKUP_RETENTION_COUNT)
}
pub fn stop_service(&self) -> Result<()> {
self.service_manager.stop_service()
}
pub fn start_service(&self) -> Result<()> {
self.service_manager.start_service()
}
pub fn restart_service(&self) -> Result<()> {
self.service_manager.restart_service()
}
pub fn verify_bundle_dependencies(&self, bundle_dir: &Path) -> Result<()> {
self.verification_manager
.verify_library_dependencies(bundle_dir)
}
pub fn update_system_assets(&self) -> Result<()> {
self.atomic_ops_manager.update_system_assets()
}
pub fn perform_install_script_update(
&self,
install_script_path: &Path,
release_bundle_dir: &Path,
signature_verified: bool,
) -> Result<()> {
info!("Performing install script update with rollback capability");
let backup_path = match self.backup_manager.create_backup(&self.app_binary_path) {
Ok(path) => Some(path),
Err(e) => {
warn!("Failed to create backup: {}, continuing without backup", e);
None
}
};
if let Err(e) = self.service_manager.stop_service() {
warn!("Failed to stop service before installation: {}", e);
}
if let Err(e) = self.atomic_ops_manager.execute_install_script(
install_script_path,
release_bundle_dir,
signature_verified,
) {
if let Some(backup_path) = backup_path {
warn!("Install script failed, attempting rollback: {}", e);
if let Err(rollback_err) = self.restore_from_backup(&backup_path) {
warn!("Rollback also failed: {}", rollback_err);
}
}
return Err(e);
}
self.service_manager.start_service()?;
self.service_manager.wait_for_service_active(None)?;
if let Err(e) = self
.backup_manager
.cleanup_old_backups(DEFAULT_BACKUP_RETENTION_COUNT)
{
warn!("Failed to cleanup old backups: {}", e);
}
info!("Install script update completed successfully");
Ok(())
}
pub fn find_binary_in_bundle(&self, release_bundle_dir: &Path) -> Result<PathBuf> {
for name in self.binary_names.iter() {
let path = release_bundle_dir.join(name);
if path.exists() && self.is_executable(&path) {
info!("Found primary binary candidate: {}", path.display());
return Ok(path);
}
}
if let Some(binary_path) = self.find_executable_binary_recursive(release_bundle_dir) {
info!("Found binary to use for update: {}", binary_path.display());
return Ok(binary_path);
}
self.log_bundle_contents(release_bundle_dir);
anyhow::bail!(
"Executable binary not found in release bundle: {}",
release_bundle_dir.display()
);
}
fn is_executable(&self, path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(path) {
metadata.is_file() && (metadata.permissions().mode() & 0o111 != 0)
} else {
false
}
}
#[cfg(not(unix))]
{
if let Ok(metadata) = std::fs::metadata(path) {
metadata.is_file()
} else {
false
}
}
}
fn find_executable_binary_recursive(&self, dir: &Path) -> Option<PathBuf> {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Ok(metadata) = std::fs::metadata(&path) {
if metadata.is_file() {
if self.is_executable(&path) {
return Some(path);
}
} else if metadata.is_dir() {
if let Some(binary) = self.find_executable_binary_recursive(&path) {
return Some(binary);
}
}
}
}
}
None
}
fn log_bundle_contents(&self, release_bundle_dir: &Path) {
info!("Contents of release bundle:");
if let Ok(entries) = std::fs::read_dir(release_bundle_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Ok(metadata) = std::fs::metadata(&path) {
let file_type = if metadata.is_file() {
"File"
} else if metadata.is_dir() {
"Directory"
} else {
"Unknown"
};
let size = if metadata.is_file() {
metadata.len().to_string()
} else {
"N/A".to_string()
};
info!(" {} ({}): {} bytes", path.display(), file_type, size);
if metadata.is_dir() {
self.log_directory_contents(&path);
}
}
}
}
}
fn log_directory_contents(&self, dir_path: &Path) {
if let Ok(subentries) = std::fs::read_dir(dir_path) {
for subentry in subentries.flatten() {
if let Ok(submetadata) = std::fs::metadata(subentry.path()) {
let subfile_type = if submetadata.is_file() {
"File"
} else if submetadata.is_dir() {
"Directory"
} else {
"Unknown"
};
info!(" - {} ({})", subentry.path().display(), subfile_type);
}
}
}
}
fn copy_assets_from_bundle(&self, release_bundle_dir: &Path) -> Result<()> {
let assets_source = release_bundle_dir.join("assets");
if !assets_source.exists() {
info!("No assets directory found in bundle, skipping asset copy");
return Ok(());
}
let install_base = self
.app_binary_path
.parent()
.context("Cannot determine install directory from app binary path")?;
let assets_target = install_base.join("assets");
Self::validate_install_path(install_base)?;
info!(
"Copying assets from {} to {}",
assets_source.display(),
assets_target.display()
);
if !assets_target.exists() {
if let Err(e) = std::fs::create_dir_all(&assets_target) {
if e.kind() == std::io::ErrorKind::PermissionDenied {
info!("Permission denied, trying with sudo to create assets directory");
let output = Command::new("sudo")
.arg("mkdir")
.arg("-p")
.arg(&assets_target)
.output()
.context("Failed to run sudo mkdir")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to create assets directory with sudo: {}", stderr);
}
} else {
return Err(e).context("Failed to create assets directory");
}
}
}
if let Err(e) = Self::copy_dir_all(&assets_source, &assets_target) {
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
if io_err.kind() == std::io::ErrorKind::PermissionDenied {
info!("Permission denied, trying with sudo to copy assets");
let output = Command::new("sudo")
.arg("cp")
.arg("-rT")
.arg(&assets_source)
.arg(&assets_target)
.output()
.context("Failed to run sudo cp")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to copy assets with sudo: {}", stderr);
}
} else {
return Err(e).context("Failed to copy assets");
}
} else {
return Err(e).context("Failed to copy assets");
}
}
if let Ok(entries) = std::fs::read_dir(&assets_target) {
info!("Assets copied to {}:", assets_target.display());
for entry in entries.flatten() {
info!(" - {}", entry.path().display());
}
}
let bg_path = assets_target.join("background/bg.png");
let splash_path = assets_target.join("splash/splash.png");
info!(
"Background exists at {}: {}",
bg_path.display(),
bg_path.exists()
);
info!(
"Splash exists at {}: {}",
splash_path.display(),
splash_path.exists()
);
info!("Assets copied successfully");
Ok(())
}
fn validate_install_path(path: &Path) -> Result<()> {
const ALLOWED_PREFIXES: &[&str] = &["/opt/", "/home/", "/tmp/"];
let path_str = path.to_string_lossy();
if ALLOWED_PREFIXES.iter().any(|p| path_str.starts_with(p)) {
Ok(())
} else {
anyhow::bail!(
"Install path '{}' is outside allowed directories ({:?})",
path.display(),
ALLOWED_PREFIXES,
)
}
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
Self::copy_dir_all(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path).with_context(|| {
format!(
"Failed to copy {} to {}",
src_path.display(),
dst_path.display()
)
})?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn setup_test_env() -> tempfile::TempDir {
let temp_dir = tempdir().unwrap();
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
temp_dir
}
fn cleanup_test_env() {
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_ota_coordinator_creation() {
let temp_dir = setup_test_env();
let app_binary_path = temp_dir.path().join("test_binary");
let coordinator = OtaCoordinator::new(app_binary_path, "test_service".to_string());
assert!(coordinator.get_service_status());
cleanup_test_env();
}
#[test]
fn test_list_backups() {
let temp_dir = setup_test_env();
let app_binary_path = temp_dir.path().join("test_binary");
let coordinator = OtaCoordinator::new(app_binary_path, "test_service".to_string());
let result = coordinator.list_backups();
assert!(
result.is_ok(),
"List backups should succeed: {:?}",
result.err()
);
let backups = result.unwrap();
assert!(backups.is_empty(), "Should have no backups initially");
cleanup_test_env();
}
#[test]
fn test_cleanup_backups() {
let temp_dir = setup_test_env();
let app_binary_path = temp_dir.path().join("test_binary");
let coordinator = OtaCoordinator::new(app_binary_path, "test_service".to_string());
let result = coordinator.cleanup_backups();
assert!(result.is_ok(), "Cleanup should succeed: {:?}", result.err());
cleanup_test_env();
}
#[test]
fn test_verify_bundle_dependencies() {
let temp_dir = setup_test_env();
let app_binary_path = temp_dir.path().join("test_binary");
let coordinator = OtaCoordinator::new(app_binary_path, "test_service".to_string());
let bundle_dir = temp_dir.path().join("bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
let result = coordinator.verify_bundle_dependencies(&bundle_dir);
assert!(
result.is_ok(),
"Bundle verification should succeed: {:?}",
result.err()
);
cleanup_test_env();
}
#[test]
fn test_service_operations() {
let temp_dir = setup_test_env();
let app_binary_path = temp_dir.path().join("test_binary");
let coordinator = OtaCoordinator::new(app_binary_path, "test_service".to_string());
assert!(coordinator.stop_service().is_ok());
assert!(coordinator.start_service().is_ok());
assert!(coordinator.restart_service().is_ok());
assert!(coordinator.get_service_status());
cleanup_test_env();
}
#[test]
fn test_find_binary_in_bundle() {
let temp_dir = setup_test_env();
let app_binary_path = temp_dir.path().join("test_binary");
let coordinator = OtaCoordinator::new(app_binary_path, "test_service".to_string());
let bundle_dir = temp_dir.path().join("bundle");
std::fs::create_dir_all(&bundle_dir).unwrap();
let binary_path = bundle_dir.join("geist");
std::fs::write(&binary_path, b"mock binary").unwrap();
#[cfg(unix)]
{
let mut perms = std::fs::metadata(&binary_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&binary_path, perms).unwrap();
}
let result = coordinator.find_binary_in_bundle(&bundle_dir);
assert!(
result.is_ok(),
"Should find binary in bundle: {:?}",
result.err()
);
cleanup_test_env();
}
#[test]
fn test_atomic_ops_manager_creation() {
let temp_dir = tempdir().unwrap();
let app_binary_path = temp_dir.path().join("test_binary");
let manager = AtomicOpsManager::new(app_binary_path.clone());
assert_eq!(manager.app_binary_path, app_binary_path);
}
#[test]
fn test_get_current_user() {
let temp_dir = tempdir().unwrap();
let manager = AtomicOpsManager::new(temp_dir.path().join("test"));
let result = manager.get_current_user();
assert!(result.is_ok(), "Should be able to determine current user");
let user = result.unwrap();
assert!(!user.is_empty(), "User should not be empty");
}
#[test]
fn test_atomic_binary_replacement() {
let temp_dir = tempdir().unwrap();
let target_path = temp_dir.path().join("target_binary");
let source_path = temp_dir.path().join("source_binary");
let source_content = b"new binary content";
fs::write(&source_path, source_content).unwrap();
let manager = AtomicOpsManager::new(target_path.clone());
let result = manager.atomic_binary_replacement(&source_path);
assert!(result.is_ok(), "Binary replacement should succeed");
assert!(target_path.exists(), "Target binary should exist");
let target_content = fs::read(&target_path).unwrap();
assert_eq!(target_content, source_content, "Content should match");
}
#[test]
fn test_set_binary_permissions() {
let temp_dir = tempdir().unwrap();
let binary_path = temp_dir.path().join("test_binary");
fs::write(&binary_path, b"test content").unwrap();
let manager = AtomicOpsManager::new(binary_path.clone());
let result = manager.set_binary_permissions();
assert!(result.is_ok(), "Setting permissions should succeed");
#[cfg(unix)]
{
let metadata = fs::metadata(&binary_path).unwrap();
let mode = metadata.permissions().mode();
assert_eq!(mode & 0o777, 0o755, "Should have 755 permissions");
}
}
#[test]
fn test_update_system_assets() {
let temp_dir = tempdir().unwrap();
let manager = AtomicOpsManager::new(temp_dir.path().join("test"));
let result = manager.update_system_assets();
assert!(result.is_ok(), "Should handle asset updates gracefully");
}
#[test]
fn test_verification_manager_creation() {
let temp_dir = tempdir().unwrap();
let app_binary_path = temp_dir.path().join("test_binary");
let manager = VerificationManager::new(app_binary_path.clone());
assert_eq!(manager.app_binary_path, app_binary_path);
}
#[test]
fn test_verify_preconditions_missing_file() {
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let nonexistent = temp_dir.path().join("nonexistent");
let result = manager.verify_preconditions(&nonexistent);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
#[test]
fn test_verify_preconditions_empty_file() {
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let empty_file = temp_dir.path().join("empty");
fs::write(&empty_file, b"").unwrap();
let result = manager.verify_preconditions(&empty_file);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_verify_preconditions_success() {
let temp_dir = tempdir().unwrap();
let target_dir = temp_dir.path().join("target_dir");
fs::create_dir_all(&target_dir).unwrap();
let manager = VerificationManager::new(target_dir.join("target_binary"));
let valid_file = temp_dir.path().join("valid_binary");
fs::write(&valid_file, b"valid binary content").unwrap();
let result = manager.verify_preconditions(&valid_file);
assert!(
result.is_ok(),
"Valid file should pass preconditions: {:?}",
result.err()
);
}
#[test]
fn test_verify_binary_format_invalid() {
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let invalid_binary = temp_dir.path().join("invalid");
fs::write(&invalid_binary, b"not a binary").unwrap();
let result = manager.verify_binary_format(&invalid_binary);
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_verify_disk_space() {
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let small_file = temp_dir.path().join("small");
fs::write(&small_file, b"small").unwrap();
let result = manager.verify_disk_space(&small_file);
assert!(
result.is_ok(),
"Small file should pass disk space check: {:?}",
result.err()
);
}
#[test]
fn test_verify_library_dependencies() {
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let bundle_dir = temp_dir.path().join("bundle");
fs::create_dir_all(&bundle_dir).unwrap();
let result = manager.verify_library_dependencies(&bundle_dir);
assert!(
result.is_ok(),
"Library dependency check should succeed: {:?}",
result.err()
);
}
#[test]
fn test_verify_system_requirements() {
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let result = manager.verify_system_requirements();
assert!(
result.is_ok(),
"System requirements check should succeed: {:?}",
result.err()
);
}
#[test]
fn test_verify_binary_executable_test_env() {
std::env::set_var("GEIST_APP_BINARY_PATH_TEST", "1");
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let mock_binary = temp_dir.path().join("mock_binary");
fs::write(&mock_binary, b"mock binary").unwrap();
let result = manager.verify_binary_executable(&mock_binary);
assert!(
result.is_ok(),
"Binary executable check should succeed in test env: {:?}",
result.err()
);
std::env::remove_var("GEIST_APP_BINARY_PATH_TEST");
}
#[test]
fn test_verify_binary_executable_non_executable() {
std::env::set_var("GEIST_FORCE_TEST_EXECUTION", "1");
let temp_dir = tempdir().unwrap();
let manager = VerificationManager::new(temp_dir.path().join("target"));
let non_executable = temp_dir.path().join("non_executable");
fs::write(&non_executable, b"not executable").unwrap();
#[cfg(unix)]
{
let mut perms = fs::metadata(&non_executable).unwrap().permissions();
perms.set_mode(0o644); fs::set_permissions(&non_executable, perms).unwrap();
}
let result = manager.verify_binary_executable(&non_executable);
#[cfg(target_os = "linux")]
{
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not executable"));
}
#[cfg(not(target_os = "linux"))]
{
let _ = result;
}
std::env::remove_var("GEIST_FORCE_TEST_EXECUTION");
}
#[test]
fn test_uninstall_manager_creation() {
let temp_dir = tempdir().unwrap();
let app_binary_path = temp_dir.path().join("test_binary");
let manager = UninstallManager::new(app_binary_path.clone());
assert_eq!(manager.app_binary_path, app_binary_path);
}
#[test]
fn test_get_service_name() {
let temp_dir = tempdir().unwrap();
let app_binary_path = temp_dir.path().join("my_app");
let manager = UninstallManager::new(app_binary_path);
assert_eq!(manager.get_service_name(), "my_app");
}
#[test]
fn test_get_service_name_fallback() {
let manager = UninstallManager::new(PathBuf::from("/"));
assert_eq!(manager.get_service_name(), "geist_supervisor");
}
#[test]
fn test_cleanup_user_data_skip() {
let temp_dir = tempdir().unwrap();
let manager = UninstallManager::new(temp_dir.path().join("test_binary"));
let user_data_dir = temp_dir.path().join(".local/share/geist-supervisor");
fs::create_dir_all(&user_data_dir).unwrap();
fs::write(user_data_dir.join("test_file"), b"test data").unwrap();
let result = manager.cleanup_user_data_impl(temp_dir.path(), false);
assert!(
result.is_ok(),
"User data cleanup should succeed: {:?}",
result.err()
);
assert!(
user_data_dir.exists(),
"User data should still exist without removal flag"
);
}
#[test]
fn test_cleanup_user_data_remove() {
let temp_dir = tempdir().unwrap();
let manager = UninstallManager::new(temp_dir.path().join("test_binary"));
let user_data_dir = temp_dir.path().join(".local/share/geist-supervisor");
fs::create_dir_all(&user_data_dir).unwrap();
fs::write(user_data_dir.join("test_file"), b"test data").unwrap();
let result = manager.cleanup_user_data_impl(temp_dir.path(), true);
assert!(
result.is_ok(),
"User data cleanup should succeed: {:?}",
result.err()
);
assert!(
!user_data_dir.exists(),
"User data should be removed with removal flag"
);
}
#[test]
fn test_backup_configuration() {
let temp_dir = tempdir().unwrap();
let manager = UninstallManager::new(temp_dir.path().join("test_binary"));
let result = manager.backup_configuration_to(temp_dir.path().to_str().unwrap().to_string());
assert!(
result.is_ok(),
"Configuration backup should succeed: {:?}",
result.err()
);
let backup_path = result.unwrap();
assert!(backup_path.to_string_lossy().contains("uninstall-backups"));
assert!(backup_path.to_string_lossy().contains("config-backup-"));
}
#[test]
fn test_verify_uninstallation_success() {
let temp_dir = tempdir().unwrap();
let app_binary_path = temp_dir.path().join("nonexistent_binary");
let manager = UninstallManager::new(app_binary_path);
let result = manager.verify_uninstallation();
assert!(result.is_ok() || result.is_err()); }
#[test]
fn test_verify_uninstallation_failure() {
let temp_dir = tempdir().unwrap();
let app_binary_path = temp_dir.path().join("existing_binary");
fs::write(&app_binary_path, b"test binary").unwrap();
let manager = UninstallManager::new(app_binary_path.clone());
let result = manager.verify_uninstallation();
assert!(
result.is_err(),
"Verification should fail when binary still exists"
);
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("verification failed"),
"Error message should indicate verification failed, got: {}",
error_msg
);
}
}