393 lines
14 KiB
Rust
393 lines
14 KiB
Rust
//! 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<i32> {
|
|
// 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<PathBuf> = 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<PathBuf> = 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!(
|
|
"{} {}",
|
|
"locked:".yellow(),
|
|
crate::tr_fmt!(
|
|
"{} entrée(s) verrouillée(s) — suppression planifiée au prochain redémarrage",
|
|
scheduled.len()
|
|
)
|
|
);
|
|
}
|
|
if scheduled.len() != locked.len() {
|
|
println!(
|
|
"{} {}",
|
|
"deferred:".cyan(),
|
|
crate::tr_fmt!(
|
|
"{} entrée(s) verrouillée(s) — le processus différé les supprimera après la fermeture de am",
|
|
locked.len() - scheduled.len()
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Delete the executable itself, then sweep the directories left
|
|
// empty behind it.
|
|
let cleanup_roots: Vec<PathBuf> = 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<PathBuf>) {
|
|
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<u16> = 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<PathBuf> {
|
|
use std::os::windows::ffi::OsStrExt;
|
|
let mut scheduled = Vec::new();
|
|
for path in locked {
|
|
let wide: Vec<u16> = 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<PathBuf> {
|
|
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<u16> = 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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf> {
|
|
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<PathBuf> = 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<PathBuf> = Vec::new();
|
|
remove_tree(Path::new("/nonexistent/agent-manager-xyz"), &mut locked);
|
|
assert!(locked.is_empty());
|
|
}
|
|
}
|