//! self-uninstall: remove agent-manager and everything it created from this //! machine — background agents are stopped first, then the data, state and //! configuration directories are deleted, then the executable itself. use crate::app::{self, App}; use anyhow::{bail, Result}; #[cfg(unix)] use anyhow::Context; use colored::Colorize; use std::path::{Path, PathBuf}; #[cfg(windows)] const MOVEFILE_DELAY_UNTIL_REBOOT: u32 = 0x4; #[cfg(windows)] const FILE_ATTRIBUTE_READONLY: u32 = 0x1; #[cfg(windows)] const INVALID_FILE_ATTRIBUTES: u32 = 0xFFFF_FFFF; #[cfg(windows)] #[link(name = "Kernel32")] extern "system" { fn MoveFileExW( lpExistingFileName: *const u16, lpNewFileName: *const u16, dwFlags: u32, ) -> i32; fn GetFileAttributesW(lpFileName: *const u16) -> u32; fn SetFileAttributesW(lpFileName: *const u16, dwFileAttributes: u32) -> i32; } pub fn run(app: &App) -> Result { // 1. Stop every managed background agent. let state = app.state.load()?; for (name, entry) in &state.installed { if let Some(pid) = entry.pid { if crate::process::is_running(pid) { app.log.info(&format!("stopping agent '{name}' (pid {pid})")); crate::process::stop_pid(pid, false, 1); } } } // 2. Collect the directories agent-manager owns. let data_root = app::data_dir(); let state_root = app::state_dir(); let mut roots: Vec<(&str, PathBuf)> = Vec::new(); if data_root.is_dir() { roots.push(("data", data_root.clone())); } if state_root != data_root && state_root.is_dir() { roots.push(("state", state_root)); } if let Some(dir) = crate::config::user_config_dir() { if dir.is_dir() { roots.push(("config", dir)); } } let exe = std::env::current_exe().ok(); let mut renamed: Option = None; if roots.is_empty() { app.log .info("nothing to remove — agent-manager left no traces on this machine"); } else { app.log.info("agent-manager will remove:"); for (label, path) in &roots { println!(" {label}: {}", path.display()); } if !app.confirm("Remove everything listed above?")? { app.log.info("cancelled — nothing was removed"); return Ok(0); } if app.dry_run() { app.log.dry("nothing removed (dry run)"); return Ok(0); } // Windows allows renaming a running executable (but not deleting // it): do it up-front so the original binary disappears immediately, // even if the deferred deletion has to wait for this process to exit. renamed = exe.as_deref().and_then(rename_for_deletion); if let Some(new) = &renamed { if let Some(old) = &exe { println!( "{} {} -> {}", "renamed:".cyan(), old.display(), new.display() ); } } // Remove everything first; any message logged *after* deleting the // state directory would recreate the log folder, so the removal // itself is silent and the report below goes to plain stdout. // The removal is tolerant: read-only attributes are cleared and files // locked by other processes (for example the running am.exe when it // lives inside the data directory) are scheduled for deletion at the // next reboot instead of failing the whole uninstall. let mut locked: Vec = Vec::new(); for (_, path) in &roots { remove_tree(path, &mut locked); } for (_, path) in &roots { if !path.exists() { println!("{} {}", "removed:".green(), path.display()); } } if !locked.is_empty() { let scheduled = schedule_locked(&locked); if !scheduled.is_empty() { println!( "{} {} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage", "locked:".yellow(), scheduled.len() ); } if scheduled.len() != locked.len() { println!( "{} {} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am", "deferred:".cyan(), locked.len() - scheduled.len() ); } } } // 3. Delete the executable itself, then sweep the directories left // empty behind it. let cleanup_roots: Vec = roots.iter().map(|(_, p)| p.clone()).collect(); match &exe { Some(e) => remove_self(e, renamed.as_deref(), &cleanup_roots)?, None => app.log .warn("cannot locate the agent-manager executable — delete it manually"), } Ok(0) } /// Recursively remove a tree, tolerating locked or read-only entries. /// Paths that could not be removed are collected into `locked` so the /// caller can schedule their deletion at the next reboot. fn remove_tree(path: &Path, locked: &mut Vec) { let meta = match std::fs::symlink_metadata(path) { Ok(m) => m, Err(_) => return, }; if meta.is_dir() { if let Ok(entries) = std::fs::read_dir(path) { for entry in entries.flatten() { remove_tree(&entry.path(), locked); } } clear_readonly(path); if std::fs::remove_dir(path).is_err() { locked.push(path.to_path_buf()); } } else { clear_readonly(path); if std::fs::remove_file(path).is_err() { locked.push(path.to_path_buf()); } } } /// Clear the read-only attribute (a common cause of "Access denied" on /// Windows, e.g. files extracted by Expand-Archive). #[cfg(windows)] fn clear_readonly(path: &Path) { use std::os::windows::ffi::OsStrExt; let wide: Vec = path .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect(); unsafe { let attrs = GetFileAttributesW(wide.as_ptr()); if attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_READONLY) != 0 { SetFileAttributesW(wide.as_ptr(), attrs & !FILE_ATTRIBUTE_READONLY); } } } #[cfg(not(windows))] fn clear_readonly(_path: &Path) {} /// Schedule every locked path for deletion at the next reboot (Windows). /// Returns the paths that could be scheduled. #[cfg(windows)] fn schedule_locked(locked: &[PathBuf]) -> Vec { use std::os::windows::ffi::OsStrExt; let mut scheduled = Vec::new(); for path in locked { let wide: Vec = path .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect(); let ok = unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 }; if ok { scheduled.push(path.clone()); } } scheduled } #[cfg(not(windows))] fn schedule_locked(_locked: &[PathBuf]) -> Vec { Vec::new() } /// Delete the running executable. On Unix the file can be unlinked while it /// runs; on Windows a hidden cmd re-tries the deletion right after this /// process exits (the file is locked while we run). Empty directories left /// behind under the removed roots are swept afterwards. fn remove_self(exe: &Path, renamed: Option<&Path>, roots: &[PathBuf]) -> Result<()> { #[cfg(unix)] { std::fs::remove_file(exe) .with_context(|| format!("cannot remove {}", exe.display()))?; // Sweep empty directories upward, but never above a removed root. let mut dir = exe.parent(); while let Some(d) = dir { if !roots.iter().any(|r| d.starts_with(r)) { break; } if std::fs::remove_dir(d).is_err() { break; } dir = d.parent(); } println!( "{} {}", "agent-manager removed:".green(), exe.display() ); return Ok(()); } #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; // The file to delete once this process exits: the renamed copy when // the up-front rename worked, the original path otherwise. let target = renamed.unwrap_or(exe); // A temporary batch file retries the deletion for a few seconds (the // image stays locked until we exit), sweeps the directories left // empty, then deletes itself. A .bat avoids every cmd /C command-line // quoting pitfall. let bat = write_cleanup_bat(exe, target, roots); if let Some(b) = &bat { // raw_arg passes the command line verbatim: regular .arg() // backslash-escapes embedded quotes, which cmd.exe does not // understand. use std::os::windows::process::CommandExt as _; let mut cleanup = std::process::Command::new("cmd"); cleanup.raw_arg("/C"); cleanup.raw_arg(format!("\"{}\"", b.display())); let _ = cleanup.spawn(); } // Belt and braces: if something keeps the file locked, the standard // uninstaller mechanism deletes it at the next reboot. let wide: Vec = target .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect(); let at_reboot = unsafe { MoveFileExW(wide.as_ptr(), std::ptr::null(), MOVEFILE_DELAY_UNTIL_REBOOT) != 0 }; if bat.is_none() && !at_reboot { bail!( "cannot schedule deletion of {} — delete it manually", exe.display() ); } if renamed.is_some() { println!( "{} {}", "agent-manager removed; the executable was renamed and will be deleted momentarily:".green(), exe.display() ); } else { println!( "{} {}", "agent-manager removed; the executable will be deleted momentarily:".green(), exe.display() ); } return Ok(()); } #[cfg(not(any(unix, windows)))] { bail!( "automatic removal of the executable is not supported on this platform; delete {} manually", exe.display() ); } } /// Rename the running executable out of the way (Windows allows renaming a /// running image — only deletion is refused while it executes). Returns the /// new path on success. #[cfg(windows)] fn rename_for_deletion(exe: &Path) -> Option { let name = exe.file_name()?.to_string_lossy(); let new = exe.with_file_name(format!("{name}.deleteme")); std::fs::rename(exe, &new).ok()?; Some(new) } #[cfg(not(windows))] fn rename_for_deletion(_exe: &Path) -> Option { None } /// Write a temporary batch file that deletes the target executable once it /// is unlocked, sweeps the directories left empty and then removes itself. #[cfg(windows)] fn write_cleanup_bat(exe: &Path, target: &Path, roots: &[PathBuf]) -> Option { let crlf = "\r\n"; let q = "\""; let mut script = String::new(); let mut line = |s: &str| { script.push_str(s); script.push_str(crlf); }; line("@echo off"); line("set n=0"); line(":retry"); line(&format!("del /f /q {q}{}{q} >nul 2>&1", target.display())); line(&format!("del /f /q {q}{}{q} >nul 2>&1", exe.display())); line(&format!( "if not exist {q}{}{q} (if not exist {q}{}{q} goto sweep)", target.display(), exe.display() )); line("set /a n+=1"); line("if %n% GEQ 60 goto end"); line("ping -n 2 127.0.0.1 >nul"); line("goto retry"); line(":sweep"); if let Some(parent) = exe.parent() { if roots.iter().any(|r| parent.starts_with(r)) { line(&format!("rd /q {q}{}{q} >nul 2>&1", parent.display())); } } for root in roots { line(&format!("rd /q {q}{}{q} >nul 2>&1", root.display())); } line(":end"); line(&format!("del {q}%~f0{q} >nul 2>&1")); let path = std::env::temp_dir().join(format!("am-uninstall-{}.bat", std::process::id())); std::fs::write(&path, script).ok()?; Some(path) } #[cfg(test)] mod tests { use super::*; #[test] fn removes_readonly_tree() { let dir = tempfile::tempdir().unwrap(); let sub = dir.path().join("sub"); std::fs::create_dir_all(&sub).unwrap(); let file = sub.join("readonly.txt"); std::fs::write(&file, "x").unwrap(); // Read-only files are a classic cause of "Access denied" on Windows. let mut perms = std::fs::metadata(&file).unwrap().permissions(); #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(true); std::fs::set_permissions(&file, perms).unwrap(); let mut locked: Vec = Vec::new(); remove_tree(dir.path(), &mut locked); assert!(!dir.path().exists(), "tree should be fully removed"); assert!(locked.is_empty(), "unexpected locked paths: {locked:?}"); } #[test] fn tolerates_missing_tree() { let mut locked: Vec = Vec::new(); remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked); assert!(locked.is_empty()); } }