diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c3a29d1..0813814fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added — `uffs --uninstall`: guided, complete removal of UFFS + +A single command removes UFFS and all of its data from the machine, as carefully +as `uffs --update`. It analyzes the install (every binary shown in OS-resolution +order, with the `ACTIVE` copy flagged), inventories every artifact with sizes +(data, cache, legacy cache, config, the Windows broker service), runs a deep +sweep that uses UFFS's own search to find stray `uffs*` files elsewhere (listed +for review, never auto-removed), prints an itemized removal plan, and only +removes after explicit consent (or `--yes`). + +- **Elevation-aware, and frugal about it.** It refuses up front (before any + effect) when a removal needs privilege the run lacks. On macOS/Linux a normal + user install needs **no `sudo`** (a real `access(W_OK)` check decides per + root); only a root-owned location or the Windows broker service / machine + install requires elevation. +- **Channel-aware.** WinGet roots are delegated to `winget uninstall`, never + hand-deleted. Manual and dev-build installs are removed directly. +- **Safe + idempotent.** `--dry-run` reviews without changing anything; + removal is best-effort (a locked/permission-denied item is reported, the rest + proceed) and idempotent (re-run to finish an interrupted one). Flags: + `--keep-config`, `--no-deep-sweep`, `--no-path`, `--scope`, `--json`. The + running binary self-deletes on exit; a post-removal step verifies the result. + See [docs/user-manual/uninstall.md](docs/user-manual/uninstall.md). + ### Added — corrupt-name forensics: keep ill-formed names visible + `--normalize-malformed` NTFS allows file and directory names that are ill-formed UTF-16 (unpaired diff --git a/crates/uffs-cli/src/commands.rs b/crates/uffs-cli/src/commands.rs index 9762b0d20..8f99be43e 100644 --- a/crates/uffs-cli/src/commands.rs +++ b/crates/uffs-cli/src/commands.rs @@ -33,6 +33,9 @@ pub mod search; pub mod stats; /// Combined `uffs --status` command. pub(crate) mod system_status; +/// `uffs --uninstall` — full UFFS removal (see +/// `docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md`). +pub(crate) mod uninstall; /// `uffs --update` — self-update detection (Phase A of the self-update design). pub(crate) mod update; diff --git a/crates/uffs-cli/src/commands/uninstall/analyze.rs b/crates/uffs-cli/src/commands/uninstall/analyze.rs new file mode 100644 index 000000000..935ca49bf --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/analyze.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Impure glue for the `uffs --uninstall` analysis (tasks U-10/U-12): turn the +//! reused Phase-A [`DetectionReport`] into resolution [`Candidate`]s, and build +//! the OS executable search-dir list the pure ordering consumes. +//! +//! Side effects are confined to reading the environment (`current_exe`, `PATH`, +//! `current_dir`, `SystemRoot`); nothing here mutates the system. + +use std::path::PathBuf; + +use super::resolve_order::Candidate; +use crate::commands::update::model::DetectionReport; + +/// Flatten the detection report's roots × binaries into resolution candidates +/// (one per discovered binary copy). +pub(crate) fn build_candidates(report: &DetectionReport) -> Vec { + let mut candidates = Vec::new(); + for root in &report.roots { + for binary in &root.binaries { + candidates.push(Candidate { + stem: binary.name.clone(), + version: binary.version.clone(), + channel: root.channel, + scope: root.scope, + dir: root.dir.clone(), + }); + } + } + candidates +} + +/// The ordered list of directories the OS searches for an unqualified +/// executable (design §5.1): the running image's dir, the system dirs +/// (Windows), the current dir, then PATH entries in order. On non-Windows this +/// is the current-exe dir, the current dir, then PATH. +pub(crate) fn search_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if let Ok(exe) = std::env::current_exe() + && let Some(parent) = exe.parent() + { + dirs.push(parent.to_path_buf()); + } + #[cfg(windows)] + { + if let Some(system_root) = std::env::var_os("SystemRoot") { + let root = PathBuf::from(system_root); + dirs.push(root.join("System32")); + dirs.push(root); + } + } + if let Ok(cwd) = std::env::current_dir() { + dirs.push(cwd); + } + if let Some(path) = std::env::var_os("PATH") { + for entry in std::env::split_paths(&path) { + dirs.push(entry); + } + } + dirs +} + +/// The directories on the current `PATH`, in order. Used to offer removal of a +/// PATH entry that points at a UFFS root. +pub(crate) fn path_entries() -> Vec { + std::env::var_os("PATH").map_or_else(Vec::new, |path| std::env::split_paths(&path).collect()) +} diff --git a/crates/uffs-cli/src/commands/uninstall/args.rs b/crates/uffs-cli/src/commands/uninstall/args.rs new file mode 100644 index 000000000..69175def1 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/args.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Argument parsing for `uffs --uninstall` (task U-03 of +//! `docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md`). +//! +//! Pure and fully unit-tested: no IO, no side effects. The flag set mirrors the +//! design doc §9 CLI surface. + +use anyhow::{Result, anyhow, bail}; + +/// Which install scope `uffs --uninstall` is allowed to act on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum UninstallScope { + /// Current user's per-user install only (`%LOCALAPPDATA%`, user PATH). + User, + /// Machine-wide install only (`%PROGRAMFILES%`, the service, machine PATH). + Machine, + /// Everything the run is permitted to touch (the default). + #[default] + All, +} + +impl UninstallScope { + /// Parse a `--scope` value (`user` | `machine` | `all`). + /// + /// # Errors + /// + /// Returns an error for any other value. + fn parse(value: &str) -> Result { + Ok(match value { + "user" => Self::User, + "machine" => Self::Machine, + "all" => Self::All, + other => bail!("invalid --scope `{other}` (expected: user | machine | all)"), + }) + } +} + +/// Parsed `uffs --uninstall` flags (design §9). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[expect( + clippy::struct_excessive_bools, + reason = "a CLI flag bag: each field is an independent user-facing on/off toggle" +)] +pub(crate) struct UninstallArgs { + /// `--dry-run`: print the analysis + removal plan, change nothing. + pub(crate) dry_run: bool, + /// `--yes` / `--assume-yes` / `-y`: skip the confirmation prompt. + pub(crate) assume_yes: bool, + /// `--keep-config`: remove binaries + caches but preserve settings/config. + pub(crate) keep_config: bool, + /// `--no-deep-sweep`: skip the cross-drive search for stray family files. + pub(crate) no_deep_sweep: bool, + /// `--no-path`: do not edit PATH (print a manual hint instead). + pub(crate) no_path: bool, + /// `--json`: emit the analysis + plan as machine-readable JSON. + pub(crate) json: bool, + /// `--scope`: restrict to user / machine / all (default `all`). + pub(crate) scope: UninstallScope, + /// `--help` / `-h`: print usage and exit. + pub(crate) help: bool, +} + +impl UninstallArgs { + /// Parse the tokens after `--uninstall` into an [`UninstallArgs`]. + /// + /// # Errors + /// + /// Returns an error for an unknown flag, a `--scope` missing its value, or + /// an invalid `--scope` value. + pub(crate) fn parse(args: &[String]) -> Result { + let mut parsed = Self::default(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--dry-run" => parsed.dry_run = true, + "--yes" | "--assume-yes" | "-y" => parsed.assume_yes = true, + "--keep-config" => parsed.keep_config = true, + "--no-deep-sweep" => parsed.no_deep_sweep = true, + "--no-path" => parsed.no_path = true, + "--json" => parsed.json = true, + "--help" | "-h" => parsed.help = true, + "--scope" => { + let value = iter + .next() + .ok_or_else(|| anyhow!("--scope requires a value: user | machine | all"))?; + parsed.scope = UninstallScope::parse(value)?; + } + flag if flag.starts_with("--scope=") => { + let value = flag.strip_prefix("--scope=").unwrap_or_default(); + parsed.scope = UninstallScope::parse(value)?; + } + other => bail!("unknown `uffs --uninstall` flag: {other}"), + } + } + Ok(parsed) + } +} + +#[cfg(test)] +mod tests { + use super::{UninstallArgs, UninstallScope}; + + fn parse(tokens: &[&str]) -> anyhow::Result { + let owned: Vec = tokens.iter().map(|tok| (*tok).to_owned()).collect(); + UninstallArgs::parse(&owned) + } + + #[test] + fn defaults_are_conservative() { + let out = parse(&[]).unwrap(); + assert_eq!(out, UninstallArgs::default()); + assert!(!out.dry_run && !out.assume_yes && !out.json); + assert_eq!(out.scope, UninstallScope::All); + } + + #[test] + fn each_flag_sets_its_field() { + let out = parse(&[ + "--dry-run", + "--yes", + "--keep-config", + "--no-deep-sweep", + "--no-path", + "--json", + ]) + .unwrap(); + assert!( + out.dry_run + && out.assume_yes + && out.keep_config + && out.no_deep_sweep + && out.no_path + && out.json + ); + } + + #[test] + fn yes_aliases_all_map() { + for tok in ["--yes", "--assume-yes", "-y"] { + assert!(parse(&[tok]).unwrap().assume_yes, "alias {tok}"); + } + } + + #[test] + fn scope_spaced_and_equals_forms() { + assert_eq!( + parse(&["--scope", "user"]).unwrap().scope, + UninstallScope::User + ); + assert_eq!( + parse(&["--scope=machine"]).unwrap().scope, + UninstallScope::Machine + ); + assert_eq!( + parse(&["--scope", "all"]).unwrap().scope, + UninstallScope::All + ); + } + + #[test] + fn scope_requires_a_value() { + parse(&["--scope"]).unwrap_err(); + } + + #[test] + fn invalid_scope_is_rejected() { + parse(&["--scope", "everything"]).unwrap_err(); + parse(&["--scope=bogus"]).unwrap_err(); + } + + #[test] + fn unknown_flag_is_rejected() { + parse(&["--purge-the-universe"]).unwrap_err(); + } + + #[test] + fn help_flag_both_forms() { + assert!(parse(&["--help"]).unwrap().help); + assert!(parse(&["-h"]).unwrap().help); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs new file mode 100644 index 000000000..0b20ab4fe --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Live [`Effects`] for `uffs --uninstall` (tasks U-41/U-42): the real +//! filesystem / process / service side effects, kept apart from the executor +//! ([`super::remove`]) so the orchestration stays testable against a fake. +//! +//! Deletions are **idempotent** (an absent target is a success). Process stop, +//! service removal, and `winget` delegation shell out (`kill`/`taskkill`, +//! `sc`, `winget`) rather than via `libc`, so this crate stays `unsafe`-free. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context as _, Result, bail}; + +use super::remove::Effects; +use crate::commands::update::model::Scope; + +/// The production effects implementation. Zero-sized; holds no state. +pub(crate) struct SystemEffects; + +impl SystemEffects { + /// Construct the live effects sink. + pub(crate) const fn new() -> Self { + Self + } +} + +impl Effects for SystemEffects { + fn stop_process(&mut self, _component: &str, pid: u32) -> Result<()> { + terminate_pid(pid) + } + + fn remove_service(&mut self, service: &str) -> Result<()> { + remove_windows_service(service) + } + + fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> { + for stem in stems { + let path = dir.join(exe_file_name(stem)); + remove_file_if_present(&path) + .with_context(|| format!("removing {}", path.display()))?; + } + Ok(()) + } + + fn delegate_winget(&mut self, package_id: &str, scope: Scope) -> Result<()> { + winget_uninstall(package_id, scope) + } + + fn remove_dir(&mut self, path: &Path) -> Result<()> { + remove_dir_if_present(path).with_context(|| format!("removing {}", path.display())) + } + + fn remove_path_entry(&mut self, dir: &Path) -> Result<()> { + remove_path_entry_impl(dir) + } +} + +/// Windows: remove `dir` from the persisted user + machine PATH (the registry), +/// each guarded so a write (and thus elevation) only happens when that scope +/// actually contains the entry. `[Environment]::SetEnvironmentVariable` +/// broadcasts `WM_SETTINGCHANGE` so open shells pick up the change. +#[cfg(windows)] +fn remove_path_entry_impl(dir: &Path) -> Result<()> { + let dir_str = dir.display().to_string(); + let escaped = dir_str.replace('\'', "''"); + let script = format!( + "$d='{escaped}'; foreach($t in 'User','Machine'){{ \ + $p=[Environment]::GetEnvironmentVariable('Path',$t); \ + if($p){{ $new=($p -split ';' | Where-Object {{ $_ -and ($_ -ne $d) }}) -join ';'; \ + if($new -ne $p){{ [Environment]::SetEnvironmentVariable('Path',$new,$t) }} }} }}" + ); + run_quiet( + Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", &script]), + &format!("removing {dir_str} from PATH"), + ) +} + +/// Unix: the shell owns PATH (rc files), so editing it automatically is unsafe. +/// Write a manual-cleanup hint to stderr instead (genuinely fallible, so no +/// `unnecessary_wraps`). +#[cfg(not(windows))] +fn remove_path_entry_impl(dir: &Path) -> Result<()> { + use std::io::Write as _; + + writeln!( + std::io::stderr(), + " note: remove {} from your shell PATH manually (e.g. ~/.profile or ~/.zshrc)", + dir.display() + ) + .context("writing PATH cleanup hint") +} + +/// Delete the running self-binaries (`uffs.exe` + `uffs-update.exe`) that +/// cannot delete themselves in place. +/// +/// Windows: a process cannot delete its own running image, so spawn a detached +/// `cmd` that waits for this process to exit, then deletes each path (the +/// classic self-delete; no FFI needed). Unix: a running binary can be unlinked +/// directly, so just remove them. +#[cfg(windows)] +pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> { + if paths.is_empty() { + return Ok(()); + } + let deletes: Vec = paths + .iter() + .map(|path| format!("del /f /q \"{}\"", path.display())) + .collect(); + // `ping` is a portable ~2s sleep; by then this process has exited and the + // images are unlocked. + let script = format!( + "ping 127.0.0.1 -n 3 >nul & {} & rem self-delete", + deletes.join(" & ") + ); + Command::new("cmd") + .args(["/c", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("scheduling self-delete")?; + Ok(()) +} + +/// Unix variant (see the Windows declaration): a running binary can be unlinked +/// directly, so remove each now. +#[cfg(not(windows))] +pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> { + for path in paths { + remove_file_if_present(path).with_context(|| format!("removing {}", path.display()))?; + } + Ok(()) +} + +/// The on-disk file name for a binary stem (`uffsd` -> `uffsd.exe` on Windows). +fn exe_file_name(stem: &str) -> String { + #[cfg(windows)] + { + format!("{stem}.exe") + } + #[cfg(not(windows))] + { + stem.to_owned() + } +} + +/// Remove a file; an already-absent target is success (idempotent). A real +/// failure (permission, sharing violation) is propagated. +fn remove_file_if_present(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(_) if confirmed_absent(path) => Ok(()), + Err(err) => Err(err.into()), + } +} + +/// Recursively remove a directory; an already-absent target is success +/// (idempotent). A real failure is propagated. +fn remove_dir_if_present(path: &Path) -> Result<()> { + match std::fs::remove_dir_all(path) { + Ok(()) => Ok(()), + Err(_) if confirmed_absent(path) => Ok(()), + Err(err) => Err(err.into()), + } +} + +/// Whether `path` is *confirmed* not to exist. `try_exists` returns `Ok(false)` +/// only when the absence is certain; an `Err` (e.g. permission denied on the +/// parent) is treated as "still present", so a genuine failure is not masked. +fn confirmed_absent(path: &Path) -> bool { + path.try_exists().is_ok_and(|exists| !exists) +} + +/// Run `command` with stdio suppressed; map a non-zero exit to an error. +fn run_quiet(command: &mut Command, what: &str) -> Result<()> { + let status = command + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("spawning {what}"))?; + if status.success() { + Ok(()) + } else { + bail!("{what} exited with {status}"); + } +} + +/// Stop a process by pid (`taskkill` on Windows, `kill` on Unix). +fn terminate_pid(pid: u32) -> Result<()> { + let pid_str = pid.to_string(); + run_quiet(&mut stop_command(&pid_str), &format!("stop of pid {pid}")) +} + +/// Windows: build the `taskkill` command for `pid_str`. +#[cfg(windows)] +fn stop_command(pid_str: &str) -> Command { + let mut command = Command::new("taskkill"); + command.args(["/PID", pid_str, "/T", "/F"]); + command +} + +/// Unix: build the `kill` command for `pid_str`. +#[cfg(not(windows))] +fn stop_command(pid_str: &str) -> Command { + let mut command = Command::new("kill"); + command.arg(pid_str); + command +} + +/// Stop + delete the broker Windows service. No-op off Windows (where no such +/// service exists, so the plan never produces this item). +#[cfg(windows)] +fn remove_windows_service(service: &str) -> Result<()> { + // Best-effort stop first; an already-stopped service is fine to delete, so + // proceed whether or not the stop succeeded. + match uffs_winsvc::stop(service) { + Ok(()) | Err(_) => {} + } + run_quiet( + Command::new("sc").args(["delete", service]), + &format!("sc delete {service}"), + ) +} + +/// Non-Windows: there is no broker service, so removal is not applicable. The +/// plan never produces this item off Windows, so this is never reached; if it +/// somehow were, erroring is the honest outcome. +#[cfg(not(windows))] +fn remove_windows_service(service: &str) -> Result<()> { + bail!("cannot remove service {service}: the broker is Windows-only") +} + +/// Delegate removal of a `WinGet`-managed root to `winget uninstall`. +fn winget_uninstall(package_id: &str, scope: Scope) -> Result<()> { + let mut command = Command::new("winget"); + command.args([ + "uninstall", + "--id", + package_id, + "--silent", + "--accept-source-agreements", + ]); + match scope { + Scope::Machine => { + command.args(["--scope", "machine"]); + } + Scope::User => { + command.args(["--scope", "user"]); + } + Scope::Unknown => {} + } + run_quiet(&mut command, &format!("winget uninstall {package_id}")) +} + +#[cfg(test)] +mod tests { + use super::{Effects as _, SystemEffects, exe_file_name}; + + /// Exercise the live deletion path on throwaway temp files (U-112): real + /// `SystemEffects`, real files, no UFFS install touched. + #[test] + fn delete_binaries_and_dir_remove_real_files_idempotently() { + let base = std::env::temp_dir().join(format!( + "uffs-uninstall-effects-{}-{}", + std::process::id(), + "u112" + )); + std::fs::create_dir_all(&base).unwrap(); + let stems = vec!["uffs".to_owned(), "uffsd".to_owned()]; + for stem in &stems { + std::fs::write(base.join(exe_file_name(stem)), b"binary").unwrap(); + } + + let mut effects = SystemEffects::new(); + // Deletes the named binaries... + effects.delete_binaries(&base, &stems).unwrap(); + assert!(!base.join(exe_file_name("uffs")).exists()); + assert!(!base.join(exe_file_name("uffsd")).exists()); + // ...and is idempotent on already-absent files. + effects.delete_binaries(&base, &stems).unwrap(); + + // remove_dir clears the tree, idempotently. + effects.remove_dir(&base).unwrap(); + assert!(!base.exists()); + effects.remove_dir(&base).unwrap(); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/inventory.rs b/crates/uffs-cli/src/commands/uninstall/inventory.rs new file mode 100644 index 000000000..8668b5b09 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/inventory.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Artifact inventory for `uffs --uninstall` (task U-11): resolve every +//! non-binary trace the UFFS family leaves — the data, cache, legacy-cache, and +//! config dirs, plus the broker service — with presence, recursive size, and +//! (later) elevation requirement. Read-only: it stats paths and queries the +//! service, never mutates anything. + +use std::path::{Path, PathBuf}; + +/// The pre-migration legacy cache dir name (mirrors the private constant in +/// `uffs_mft::cache`; kept in sync intentionally so the analysis can offer to +/// remove a stale legacy cache). +const LEGACY_CACHE_DIR_NAME: &str = "uffs_index_cache"; + +/// Kind of inventoried artifact (for grouping + rendering). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArtifactKind { + /// Lifecycle / runtime data dir (`%LOCALAPPDATA%\uffs\`): the daemon pid + + /// state and the update working dir (snapshots, journal, backups). + Data, + /// Encrypted cache dir (`%LOCALAPPDATA%\uffs\cache\`): per-drive compact + /// indexes, USN cursors, runtime. + Cache, + /// Pre-migration legacy cache dir (`%TEMP%\uffs_index_cache\`). + LegacyCache, + /// Per-user config / settings dir. + Config, +} + +impl ArtifactKind { + /// Short human label. + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Data => "data", + Self::Cache => "cache", + Self::LegacyCache => "legacy-cache", + Self::Config => "config", + } + } +} + +/// One inventoried filesystem artifact (a directory tree). +#[derive(Debug, Clone)] +pub(crate) struct ArtifactDir { + /// What kind of artifact this is. + pub(crate) kind: ArtifactKind, + /// The directory path. + pub(crate) path: PathBuf, + /// Whether it currently exists on disk. + pub(crate) exists: bool, + /// Recursive size in bytes (0 when absent or unreadable). + pub(crate) size_bytes: u64, +} + +/// State of the Windows broker service (`UffsAccessBroker`). Off Windows the +/// service concept does not exist, so it always reads `Absent` there (via the +/// cross-platform `uffs_winsvc` stub), which is correct for removal purposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BrokerServiceState { + /// Installed (running or stopped). Removal needs elevation. + Installed, + /// Not installed (or non-Windows). + Absent, +} + +impl BrokerServiceState { + /// Short human label. + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Installed => "installed", + Self::Absent => "absent", + } + } +} + +/// The full non-binary inventory. +#[derive(Debug, Clone)] +pub(crate) struct Inventory { + /// The artifact directories (present or not). + pub(crate) dirs: Vec, + /// Broker-service state. + pub(crate) broker_service: BrokerServiceState, +} + +/// Resolve the inventory: stat the known artifact dirs and query the broker +/// service. Read-only. +pub(crate) fn collect() -> Inventory { + let mut dirs = vec![ + stat_dir( + ArtifactKind::Data, + crate::commands::update::procinfo::lifecycle_dir(), + ), + stat_dir(ArtifactKind::Cache, uffs_mft::cache::secure_cache_dir()), + stat_dir( + ArtifactKind::LegacyCache, + std::env::temp_dir().join(LEGACY_CACHE_DIR_NAME), + ), + ]; + // On some platforms (e.g. macOS) the config base equals the data base, so + // skip the config entry when it would duplicate a dir already listed — + // removal must never act on the same path twice. + if let Some(config) = config_dir() + && !dirs.iter().any(|existing| existing.path == config) + { + dirs.push(stat_dir(ArtifactKind::Config, config)); + } + Inventory { + dirs, + broker_service: broker_service_state(), + } +} + +/// The per-user UFFS config / settings dir, if a config base is resolvable. +fn config_dir() -> Option { + dirs_next::config_dir().map(|base| base.join("uffs")) +} + +/// Stat a directory: existence + recursive size. +fn stat_dir(kind: ArtifactKind, path: PathBuf) -> ArtifactDir { + let exists = path.is_dir(); + let size_bytes = if exists { dir_size_bytes(&path) } else { 0 }; + ArtifactDir { + kind, + path, + exists, + size_bytes, + } +} + +/// Recursive byte size of `dir` (best-effort; unreadable entries count as 0). +/// `DirEntry::metadata` does not traverse symlinks, so this cannot loop. +fn dir_size_bytes(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + let mut total: u64 = 0; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { + continue; + }; + if meta.is_dir() { + total = total.saturating_add(dir_size_bytes(&entry.path())); + } else { + total = total.saturating_add(meta.len()); + } + } + total +} + +/// Query the broker-service state. `uffs_winsvc::is_installed` stubs to `false` +/// off Windows, so this reads `Absent` there. +fn broker_service_state() -> BrokerServiceState { + if uffs_winsvc::is_installed(uffs_broker_protocol::SERVICE_NAME) { + BrokerServiceState::Installed + } else { + BrokerServiceState::Absent + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/journal.rs b/crates/uffs-cli/src/commands/uninstall/journal.rs new file mode 100644 index 000000000..1bcc3eebd --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/journal.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Crash-awareness for `uffs --uninstall` (task U-90/U-91). +//! +//! The removal operations are **idempotent** (deletes are `try_exists`-guarded, +//! service/winget removals no-op when already gone, the self-delete is +//! reboot-deferred), so resuming an interrupted uninstall is simply *running it +//! again*: re-detection finds whatever is left and removes it. This is the key +//! difference from the self-update flow, whose non-idempotent binary swaps need +//! a full replay journal. +//! +//! So all this needs is a small **in-progress marker**, written to the system +//! temp dir (which survives the lifecycle-dir deletion). If a launch finds the +//! marker, a prior run was interrupted; the CLI says so and the (idempotent) +//! run completes the job. The marker is cleared on a clean finish. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, Result}; + +/// Where the in-progress marker lives: the system temp dir, outside every +/// directory the uninstall deletes. +fn marker_path() -> PathBuf { + std::env::temp_dir().join("uffs-uninstall.in-progress") +} + +/// Record that an uninstall is in progress. +/// +/// # Errors +/// +/// Returns an error if the marker cannot be written. +pub(crate) fn begin() -> Result<()> { + write_marker(&marker_path()) +} + +/// Clear the in-progress marker on a clean finish. +/// +/// # Errors +/// +/// Returns an error if the marker exists but cannot be removed. +pub(crate) fn finish() -> Result<()> { + clear_marker(&marker_path()) +} + +/// Whether a previous uninstall was interrupted (the marker survived). +pub(crate) fn was_interrupted() -> bool { + marker_present(&marker_path()) +} + +/// Write the marker at `path`. +fn write_marker(path: &Path) -> Result<()> { + std::fs::write(path, "uffs uninstall in progress") + .with_context(|| format!("writing uninstall marker {}", path.display())) +} + +/// Remove the marker at `path`; an already-absent marker is success. +fn clear_marker(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(_) if !marker_present(path) => Ok(()), + Err(err) => Err(err.into()), + } +} + +/// Whether the marker at `path` exists. +fn marker_present(path: &Path) -> bool { + path.try_exists().unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::{clear_marker, marker_present, write_marker}; + + #[test] + fn marker_round_trips() { + let path = std::env::temp_dir().join("uffs-uninstall-journal-test.marker"); + // Start clean. + clear_marker(&path).unwrap(); + assert!(!marker_present(&path)); + // Begin → present. + write_marker(&path).unwrap(); + assert!(marker_present(&path)); + // Finish → gone, and finishing again is idempotent. + clear_marker(&path).unwrap(); + assert!(!marker_present(&path)); + clear_marker(&path).unwrap(); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs new file mode 100644 index 000000000..fdbed7580 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `uffs --uninstall` — full removal of the UFFS family from the machine. +//! +//! Design + plan: +//! - `docs/dev/architecture/UFFS-Uninstall-Feasibility-and-Design.md` +//! - `docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md` +//! +//! This is the command entry point. M1 implements the read-only **analysis** +//! (the binary resolution table); the plan, consent, and removal phases land in +//! sibling modules as the later milestones progress. + +mod analyze; +mod args; +mod effects; +mod inventory; +mod journal; +mod plan; +mod remove; +mod render; +mod resolve_order; +mod sweep; +mod verify; + +use std::path::PathBuf; + +use anyhow::{Context as _, Result, bail}; +use args::UninstallArgs; +use plan::{PlanTarget, RemovalPlan}; + +/// Entry point for `uffs --uninstall`. `args` is every token after the +/// `--uninstall` command token. +/// +/// # Errors +/// +/// Propagates argument-parse failures (and, in later milestones, analysis and +/// removal failures). +pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { + let parsed = UninstallArgs::parse(args)?; + if parsed.help { + print_help(); + return Ok(()); + } + + // M9 crash-awareness: if a prior uninstall was interrupted, say so. Because + // removal is idempotent, this (re-)run simply completes it. + if journal::was_interrupted() { + render::print_resumed_note(); + } + + // M1 analysis: reuse the self-update Phase-A detection for the binary + // resolution table, then inventory the non-binary artifacts. + let report = crate::commands::update::detect(); + let candidates = analyze::build_candidates(&report); + let resolved = resolve_order::group_and_resolve(&candidates, &analyze::search_dirs()); + let inventory = inventory::collect(); + // M2: turn the analysis into an ordered removal plan (read-only). + let removal_plan = plan::build_plan(&report, &inventory, &parsed, &analyze::path_entries()); + + if parsed.json { + render::print_json(&resolved, &inventory, &removal_plan); + return Ok(()); + } + + render::print_resolution_table(&resolved); + render::print_inventory(&inventory); + render::print_plan(&removal_plan); + + // M7 deep sweep: while the daemon is still up, ask UFFS itself for stray + // family files elsewhere on the indexed drives. Read-only; reported only. + if !parsed.no_deep_sweep { + let known = plan_dirs(&removal_plan); + let mut search = sweep::DaemonSearch; + if let Ok(strays) = sweep::find_strays(&mut search, &known) { + render::print_strays(&strays); + } + } + + if parsed.dry_run { + print_dry_run_footer(); + return Ok(()); + } + + // M3 elevation gate (U-30): refuse before any effect when the plan needs + // privilege the current process lacks. `uffs_mft::platform::is_elevated` is + // cross-platform (Windows token check; Unix effective-uid 0), unlike the + // Windows-only `uffs_winsvc::is_elevated`. + if removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() { + render::print_elevation_refusal(&removal_plan); + bail!("uninstall needs Administrator for the items listed above; re-run elevated"); + } + + if removal_plan.is_empty() { + return Ok(()); + } + + // M4 consent (U-21): unless --yes, require explicit confirmation (default No) + // before any destructive effect. + if !parsed.assume_yes && !confirm_removal()? { + print_aborted(); + return Ok(()); + } + + // M9: mark the run in progress (survives the lifecycle-dir deletion) so an + // interruption is detectable next launch. Best-effort: a failed marker write + // must not block the uninstall, but we surface it honestly. + if let Err(err) = journal::begin() { + render::print_journal_warning(&err); + } + + // M4 execute (U-40..42): run the ordered plan against the live effects sink, + // best-effort. The outcome reports what was removed and what failed. + let mut effects = effects::SystemEffects::new(); + let outcome = remove::execute(&removal_plan, &mut effects); + render::print_outcome(&outcome); + + // M8 self-delete (U-80): the running uffs.exe (+ uffs-update.exe) cannot + // delete themselves in place; schedule a deferred delete. If even scheduling + // fails, say so rather than hiding it. + let self_paths = self_binaries(); + if let Err(err) = effects::schedule_self_delete(&self_paths) { + render::print_self_delete_warning(&err); + } + + // M8 verify (U-81): confirm the targeted locations are gone, excluding the + // reboot-deferred self-binaries handled above. + let to_check: Vec = plan_dirs(&removal_plan) + .into_iter() + .filter(|dir| { + !self_paths + .iter() + .any(|self_path| self_path.starts_with(dir)) + }) + .collect(); + render::print_verification(&verify::still_present(&to_check)); + + // M9: clear the in-progress marker now the run finished. + if let Err(err) = journal::finish() { + render::print_journal_warning(&err); + } + Ok(()) +} + +/// The running self-binaries that cannot be deleted in place: the current +/// `uffs` executable and its sibling `uffs-update`. +fn self_binaries() -> Vec { + let Ok(exe) = std::env::current_exe() else { + return Vec::new(); + }; + let mut paths = vec![exe.clone()]; + if let Some(dir) = exe.parent() { + let updater = if cfg!(windows) { + "uffs-update.exe" + } else { + "uffs-update" + }; + paths.push(dir.join(updater)); + } + paths +} + +/// The directories the plan acts on, used to dedup deep-sweep hits (a stray +/// already inside a planned dir is not a separate finding). +fn plan_dirs(plan: &RemovalPlan) -> Vec { + plan.items() + .filter_map(|item| match &item.target { + PlanTarget::DeleteBinaries { dir, .. } + | PlanTarget::DelegateWinget { dir, .. } + | PlanTarget::RemovePathEntry { dir } => Some(dir.clone()), + PlanTarget::DeleteDir { path, .. } => Some(path.clone()), + PlanTarget::StopProcess { .. } | PlanTarget::RemoveService { .. } => None, + }) + .collect() +} + +/// Prompt for confirmation before any removal. Default (empty / anything but +/// `y`/`yes`) is **No**. +#[expect(clippy::print_stdout, reason = "interactive CLI prompt")] +fn confirm_removal() -> Result { + use std::io::Write as _; + + print!("\nProceed with removal? [y/N] "); + std::io::stdout() + .flush() + .context("flushing the confirmation prompt")?; + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("reading confirmation")?; + Ok(matches!( + line.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +/// Footer printed after a `--dry-run` plan. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_dry_run_footer() { + println!("\nDry run: nothing was removed."); +} + +/// Message printed when the user declines the confirmation. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_aborted() { + println!("Aborted. Nothing was removed."); +} + +/// Print `uffs --uninstall` usage. +#[expect(clippy::print_stdout, reason = "intentional help output")] +fn print_help() { + println!( + "uffs --uninstall — remove UFFS and all of its data from this machine\n\ + \n\ + USAGE:\n\ + \x20 uffs --uninstall [flags]\n\ + \n\ + FLAGS:\n\ + \x20 --dry-run Show the analysis + removal plan, change nothing\n\ + \x20 --yes, -y Skip the confirmation prompt\n\ + \x20 --keep-config Remove binaries + caches but keep settings/config\n\ + \x20 --no-deep-sweep Skip the cross-drive search for stray UFFS files\n\ + \x20 --no-path Do not edit PATH (print a manual hint instead)\n\ + \x20 --scope Restrict to user | machine | all (default: all)\n\ + \x20 --json Emit the analysis + plan as JSON\n\ + \x20 --help, -h Show this help" + ); +} diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs new file mode 100644 index 000000000..5fae0e4cf --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -0,0 +1,609 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Removal-plan construction for `uffs --uninstall` (task U-20 of +//! `docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md`). +//! +//! Pure: turns the analysis ([`DetectionReport`] + [`Inventory`]) into an +//! ordered, itemized [`RemovalPlan`], honoring `--keep-config` / `--scope`. +//! No IO, fully unit-tested. `WinGet` roots become a `winget uninstall` +//! delegation, never a hand-delete (design §7). +//! +//! Each [`PlanItem`] carries a structured [`PlanTarget`] — the single source of +//! truth that both the renderer (description / `--json`) and the executor +//! (M4 `remove`) consume, so what is shown is exactly what is removed. + +use std::path::{Path, PathBuf}; + +use super::args::{UninstallArgs, UninstallScope}; +use super::inventory::{ArtifactKind, BrokerServiceState, Inventory}; +use crate::commands::update::model::{Channel, DetectionReport, InstallRoot, Scope}; + +/// The `WinGet` package id UFFS publishes under. +pub(crate) const WINGET_PACKAGE_ID: &str = "SkyLLC.UFFS"; + +/// The concrete target of a plan item: everything the executor needs, and +/// everything the renderer describes. Group ordering (in [`build_plan`]) plus +/// this discriminant define the safe removal order. +#[derive(Debug, Clone)] +pub(crate) enum PlanTarget { + /// Stop a running UFFS process (daemon / MCP gateway). + StopProcess { + /// Component label (e.g. `daemon`). + component: String, + /// OS process id. + pid: u32, + }, + /// Stop + delete the broker Windows service. + RemoveService { + /// Service name (`UffsAccessBroker`). + service: String, + }, + /// Delete the UFFS binaries in an unmanaged / dev-build root. + DeleteBinaries { + /// The root directory. + dir: PathBuf, + /// The binary stems present in the root (no `.exe` suffix). + stems: Vec, + }, + /// Delegate a `WinGet`-managed root to `winget uninstall`. + DelegateWinget { + /// The package id to uninstall. + package_id: String, + /// The root's install scope (user / machine). + scope: Scope, + /// The root directory (for the description). + dir: PathBuf, + }, + /// Recursively delete a data / cache / config directory. + DeleteDir { + /// The directory to remove. + path: PathBuf, + /// The artifact-kind label (e.g. `cache`), for the description. + label: &'static str, + }, + /// Remove a (provably UFFS) directory from PATH. + RemovePathEntry { + /// The PATH entry to remove. + dir: PathBuf, + }, +} + +impl PlanTarget { + /// Short verb label (used in `--json`). + pub(crate) const fn action_label(&self) -> &'static str { + match *self { + Self::StopProcess { .. } => "stop-process", + Self::RemoveService { .. } => "remove-service", + Self::DeleteBinaries { .. } => "delete-binaries", + Self::DelegateWinget { .. } => "delegate-winget", + Self::DeleteDir { .. } => "delete-dir", + Self::RemovePathEntry { .. } => "remove-path-entry", + } + } + + /// Human, one-line description of the target. + pub(crate) fn describe(&self) -> String { + match self { + Self::StopProcess { component, pid } => format!("{component} (pid {pid})"), + Self::RemoveService { service } => format!("Stop + delete service {service}"), + Self::DeleteBinaries { dir, stems } => { + format!("{} binaries in {}", stems.len(), dir.display()) + } + Self::DelegateWinget { + package_id, + scope, + dir, + } => format!( + "winget uninstall {package_id} ({} root: {})", + scope.label(), + dir.display() + ), + Self::DeleteDir { path, label } => format!("{label} ({})", path.display()), + Self::RemovePathEntry { dir } => format!("PATH entry {}", dir.display()), + } + } +} + +/// Coarse scope of a plan item, for `--scope` filtering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ItemScope { + /// A per-user artifact (`%LOCALAPPDATA%`, a user-scope root). + User, + /// A machine-wide artifact (the service, a `%PROGRAMFILES%` root). + Machine, + /// Scope-agnostic (a running process). + Any, +} + +/// One unit of removal work. +#[derive(Debug, Clone)] +pub(crate) struct PlanItem { + /// What to remove (structured; drives both render and execute). + pub(crate) target: PlanTarget, + /// Whether performing it requires Administrator. + pub(crate) needs_elevation: bool, + /// Coarse scope, for `--scope` filtering. + pub(crate) scope: ItemScope, + /// Bytes this item reclaims (0 for non-filesystem actions). + pub(crate) bytes: u64, +} + +/// A named, ordered group of plan items (Services, Processes, ...). +#[derive(Debug, Clone)] +pub(crate) struct PlanGroup { + /// Group heading. + pub(crate) title: &'static str, + /// Items in the group. + pub(crate) items: Vec, +} + +/// The full ordered removal plan. +#[derive(Debug, Clone, Default)] +pub(crate) struct RemovalPlan { + /// Groups in safe removal order. + pub(crate) groups: Vec, +} + +impl RemovalPlan { + /// Iterate every item across all groups, in order. + pub(crate) fn items(&self) -> impl Iterator { + self.groups.iter().flat_map(|group| &group.items) + } + + /// Total bytes the plan would reclaim. + pub(crate) fn total_bytes(&self) -> u64 { + self.items() + .map(|item| item.bytes) + .fold(0, u64::saturating_add) + } + + /// Whether any item requires Administrator. + pub(crate) fn requires_elevation(&self) -> bool { + self.items().any(|item| item.needs_elevation) + } + + /// Number of items across all groups. + pub(crate) fn item_count(&self) -> usize { + self.groups.iter().map(|group| group.items.len()).sum() + } + + /// True when there is nothing to remove. + pub(crate) fn is_empty(&self) -> bool { + self.item_count() == 0 + } +} + +/// Build the ordered removal plan from the analysis + flags. `path_entries` is +/// the live PATH (used to offer removal of entries that point at a UFFS root). +pub(crate) fn build_plan( + report: &DetectionReport, + inventory: &Inventory, + args: &UninstallArgs, + path_entries: &[PathBuf], +) -> RemovalPlan { + let mut groups: Vec = Vec::new(); + + // 1. Services (the broker, elevated) — removed first conceptually. + if inventory.broker_service == BrokerServiceState::Installed { + let item = PlanItem { + target: PlanTarget::RemoveService { + service: uffs_broker_protocol::SERVICE_NAME.to_owned(), + }, + needs_elevation: true, + scope: ItemScope::Machine, + bytes: 0, + }; + push_group(&mut groups, "Services", vec![item], args.scope); + } + + // 2. Processes (stopped before their binaries are deleted). + let processes: Vec = report + .running + .iter() + .map(|process| PlanItem { + target: PlanTarget::StopProcess { + component: process.component.label().to_owned(), + pid: process.pid, + }, + needs_elevation: false, + scope: ItemScope::Any, + bytes: 0, + }) + .collect(); + push_group( + &mut groups, + "Processes (stopped first)", + processes, + args.scope, + ); + + // 3. Binaries — per root: unmanaged/dev delete, winget delegate. + let binaries: Vec = report.roots.iter().filter_map(binary_item).collect(); + push_group(&mut groups, "Binaries", binaries, args.scope); + + // 4. Data / cache / config dirs that exist (skip config under --keep-config). + let dirs: Vec = inventory + .dirs + .iter() + .filter(|dir| dir.exists) + .filter(|dir| !(args.keep_config && dir.kind == ArtifactKind::Config)) + .map(|dir| PlanItem { + target: PlanTarget::DeleteDir { + path: dir.path.clone(), + label: dir.kind.label(), + }, + needs_elevation: false, + scope: ItemScope::User, + bytes: dir.size_bytes, + }) + .collect(); + push_group(&mut groups, "Data / cache / config", dirs, args.scope); + + // 5. PATH entries that point at a removed unmanaged/dev root — provably UFFS + // (the exact dir we just deleted), so safe to drop. WinGet roots are managed + // by winget; never touched here. Skipped under --no-path. + if !args.no_path { + let path_items: Vec = report + .roots + .iter() + .filter(|root| !root.binaries.is_empty() && !matches!(root.channel, Channel::WinGet)) + .filter(|root| { + path_entries + .iter() + .any(|entry| paths_equal_ignore_case(entry, &root.dir)) + }) + .map(|root| { + let machine = matches!(root.scope, Scope::Machine); + PlanItem { + target: PlanTarget::RemovePathEntry { + dir: root.dir.clone(), + }, + needs_elevation: machine, + scope: if machine { + ItemScope::Machine + } else { + ItemScope::User + }, + bytes: 0, + } + }) + .collect(); + push_group(&mut groups, "PATH", path_items, args.scope); + } + + RemovalPlan { groups } +} + +/// Case-insensitive path equality (Windows file systems + PATH entries vary in +/// case; a redundant exact match is what we require before touching PATH). +fn paths_equal_ignore_case(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +/// Build the per-root binary plan item, or `None` for an empty root. +fn binary_item(root: &InstallRoot) -> Option { + if root.binaries.is_empty() { + return None; + } + let needs_elevation = binaries_need_escalation(root.scope, &root.dir); + let item_scope = if needs_elevation { + ItemScope::Machine + } else { + ItemScope::User + }; + let target = match root.channel { + Channel::WinGet => PlanTarget::DelegateWinget { + package_id: WINGET_PACKAGE_ID.to_owned(), + scope: root.scope, + dir: root.dir.clone(), + }, + Channel::Unmanaged | Channel::DevBuild | Channel::Unknown => PlanTarget::DeleteBinaries { + dir: root.dir.clone(), + stems: root.binaries.iter().map(|bin| bin.name.clone()).collect(), + }, + }; + Some(PlanItem { + target, + needs_elevation, + scope: item_scope, + bytes: 0, + }) +} + +/// Whether removing the UFFS binaries in `dir` (of install `scope`) needs +/// privilege escalation the current user may not have. +/// +/// Windows: machine-scope roots (`%PROGRAMFILES%`) need Administrator; the +/// classified scope already captures this. +#[cfg(windows)] +const fn binaries_need_escalation(scope: Scope, _dir: &Path) -> bool { + matches!(scope, Scope::Machine) +} + +/// Unix variant (see the Windows declaration): probe `dir` with a POSIX +/// `access(W_OK)` check — a user-owned root (`~/bin`, `~/.cargo/bin`, a dev +/// build) is removable without `sudo`, while a root-owned one +/// (`/usr/local/bin`) is flagged before the executor tries. +#[cfg(unix)] +fn binaries_need_escalation(_scope: Scope, dir: &Path) -> bool { + !uffs_mft::platform::dir_user_writable(dir) +} + +/// Fallback for non-Windows, non-Unix targets: never require escalation. +#[cfg(not(any(windows, unix)))] +fn binaries_need_escalation(_scope: Scope, _dir: &Path) -> bool { + false +} + +/// Apply the `--scope` filter and append the group only if it has items left. +fn push_group( + groups: &mut Vec, + title: &'static str, + items: Vec, + scope: UninstallScope, +) { + let kept: Vec = items + .into_iter() + .filter(|item| scope_admits(scope, item.scope)) + .collect(); + if !kept.is_empty() { + groups.push(PlanGroup { title, items: kept }); + } +} + +/// Whether a `--scope` request admits an item of the given scope. +const fn scope_admits(requested: UninstallScope, item: ItemScope) -> bool { + match (requested, item) { + (UninstallScope::All, _) + | (_, ItemScope::Any) + | (UninstallScope::User, ItemScope::User) + | (UninstallScope::Machine, ItemScope::Machine) => true, + (UninstallScope::User, ItemScope::Machine) | (UninstallScope::Machine, ItemScope::User) => { + false + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{PlanTarget, RemovalPlan, build_plan}; + use crate::commands::uninstall::args::{UninstallArgs, UninstallScope}; + use crate::commands::uninstall::inventory::{ + ArtifactDir, ArtifactKind, BrokerServiceState, Inventory, + }; + use crate::commands::update::model::{ + BinaryInfo, Channel, Component, DetectionReport, InstallRoot, RunningProcess, Scope, + }; + + fn root(channel: Channel, scope: Scope, dir: &str) -> InstallRoot { + InstallRoot { + dir: PathBuf::from(dir), + channel, + scope, + anchored_by: Vec::new(), + binaries: vec![BinaryInfo { + name: "uffs".to_owned(), + version: Some("0.6.16".to_owned()), + }], + } + } + + fn inventory(broker: BrokerServiceState, config_size: u64) -> Inventory { + Inventory { + dirs: vec![ + ArtifactDir { + kind: ArtifactKind::Cache, + path: PathBuf::from("/x/cache"), + exists: true, + size_bytes: 2048, + }, + ArtifactDir { + kind: ArtifactKind::Config, + path: PathBuf::from("/x/config"), + exists: true, + size_bytes: config_size, + }, + ], + broker_service: broker, + } + } + + fn has_target(plan: &RemovalPlan, predicate: impl Fn(&PlanTarget) -> bool) -> bool { + plan.items().any(|item| predicate(&item.target)) + } + + /// Build a plan with no PATH entries (PATH has its own dedicated test). + fn built(report: &DetectionReport, inventory: &Inventory, args: &UninstallArgs) -> RemovalPlan { + build_plan(report, inventory, args, &[]) + } + + #[test] + fn winget_root_is_delegated_not_deleted() { + let report = DetectionReport { + roots: vec![root(Channel::WinGet, Scope::User, r"C:\winget\uffs")], + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::DelegateWinget { .. } + ))); + assert!(!has_target(&plan, |target| matches!( + target, + PlanTarget::DeleteBinaries { .. } + ))); + } + + #[test] + fn machine_root_needs_elevation() { + let report = DetectionReport { + roots: vec![root( + Channel::Unmanaged, + Scope::Machine, + r"C:\Program Files\uffs", + )], + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(plan.requires_elevation()); + } + + #[test] + fn service_present_requires_elevation_and_is_first() { + let report = DetectionReport { + roots: Vec::new(), + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &UninstallArgs::default(), + ); + assert!(plan.requires_elevation()); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + ))); + assert_eq!(plan.groups.first().expect("a group").title, "Services"); + } + + #[test] + fn keep_config_drops_the_config_dir() { + let report = DetectionReport { + roots: Vec::new(), + running: Vec::new(), + }; + let inv = inventory(BrokerServiceState::Absent, 4096); + let with_config = built(&report, &inv, &UninstallArgs::default()); + let keep = UninstallArgs { + keep_config: true, + ..UninstallArgs::default() + }; + let without_config = built(&report, &inv, &keep); + assert!(with_config.total_bytes() > without_config.total_bytes()); + } + + #[test] + fn scope_user_excludes_the_machine_service() { + let report = DetectionReport { + roots: Vec::new(), + running: Vec::new(), + }; + let user_only = UninstallArgs { + scope: UninstallScope::User, + ..UninstallArgs::default() + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &user_only, + ); + assert!(!has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + ))); + assert!(!plan.requires_elevation()); + } + + #[test] + fn running_process_becomes_a_stop_item() { + let report = DetectionReport { + roots: Vec::new(), + running: vec![RunningProcess { + component: Component::Daemon, + pid: 4242, + image_path: None, + command_line: None, + version: None, + }], + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::StopProcess { .. } + ))); + } + + #[test] + fn path_entry_matching_a_removed_root_is_offered_and_respects_no_path() { + let report = DetectionReport { + roots: vec![root(Channel::Unmanaged, Scope::User, r"C:\Users\me\bin")], + running: Vec::new(), + }; + let inv = inventory(BrokerServiceState::Absent, 1024); + // Case-insensitive match of a PATH entry to the removed root → offered. + let on_path = [PathBuf::from(r"c:\users\me\bin")]; + let offered = build_plan(&report, &inv, &UninstallArgs::default(), &on_path); + assert!(has_target(&offered, |target| matches!( + target, + PlanTarget::RemovePathEntry { .. } + ))); + // --no-path suppresses the PATH group entirely. + let no_path = UninstallArgs { + no_path: true, + ..UninstallArgs::default() + }; + let suppressed = build_plan(&report, &inv, &no_path, &on_path); + assert!(!has_target(&suppressed, |target| matches!( + target, + PlanTarget::RemovePathEntry { .. } + ))); + // A PATH entry that does not match any root is never touched. + let unrelated = [PathBuf::from(r"C:\unrelated")]; + let untouched = build_plan(&report, &inv, &UninstallArgs::default(), &unrelated); + assert!(!has_target(&untouched, |target| matches!( + target, + PlanTarget::RemovePathEntry { .. } + ))); + } + + #[cfg(unix)] + #[test] + fn unix_user_writable_root_skips_escalation_root_owned_flags_it() { + use std::path::Path; + + use super::binaries_need_escalation; + // The temp dir is user-writable → removable without sudo. + assert!(!binaries_need_escalation( + Scope::Unknown, + &std::env::temp_dir() + )); + // A non-existent / unwritable path → flagged for escalation. + assert!(binaries_need_escalation( + Scope::Unknown, + Path::new("/nonexistent/uffs-escalation-probe") + )); + } + + #[cfg(windows)] + #[test] + fn windows_escalation_follows_machine_scope() { + use std::path::Path; + + use super::binaries_need_escalation; + assert!(binaries_need_escalation( + Scope::Machine, + Path::new(r"C:\Program Files\uffs") + )); + assert!(!binaries_need_escalation( + Scope::User, + Path::new(r"C:\Users\me\bin") + )); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/remove.rs b/crates/uffs-cli/src/commands/uninstall/remove.rs new file mode 100644 index 000000000..e66adefd3 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! The `uffs --uninstall` removal executor (task U-40). +//! +//! [`execute`] walks a [`RemovalPlan`] in order and dispatches each item to an +//! injected [`Effects`] implementation, recording a per-item outcome. It is +//! **best-effort**: a failing item is recorded and the rest still run, so one +//! locked file never strands the cleanup (crash-resume is added in M9). +//! +//! All side effects live behind the [`Effects`] trait, so the orchestration is +//! unit-tested with a recording fake — zero real deletions in tests. The live +//! implementation is `super::effects::SystemEffects`. + +use std::path::Path; + +use anyhow::Result; + +use super::plan::{PlanTarget, RemovalPlan}; +use crate::commands::update::model::Scope; + +/// The side effects the executor performs, injected so the walk is testable. +pub(crate) trait Effects { + /// Stop a running UFFS process by component label + pid. + fn stop_process(&mut self, component: &str, pid: u32) -> Result<()>; + /// Stop and delete the broker Windows service. + fn remove_service(&mut self, service: &str) -> Result<()>; + /// Delete the named binary stems inside `dir` (absent ones are a no-op). + fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()>; + /// Hand a `WinGet`-managed root to `winget uninstall`. + fn delegate_winget(&mut self, package_id: &str, scope: Scope) -> Result<()>; + /// Recursively delete a directory (absent is a no-op). + fn remove_dir(&mut self, path: &Path) -> Result<()>; + /// Remove `dir` from the user's PATH (Windows: the registry; Unix: print a + /// manual hint, since the shell owns PATH). + fn remove_path_entry(&mut self, dir: &Path) -> Result<()>; +} + +/// Per-item outcome. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ItemStatus { + /// The item completed (or was already absent). + Done, + /// The item failed; carries the error text. + Failed(String), +} + +/// The result of executing a whole plan: one entry per item, in order. +#[derive(Debug, Clone, Default)] +pub(crate) struct RemovalOutcome { + /// `(description, status)` for every item the executor touched. + pub(crate) results: Vec<(String, ItemStatus)>, +} + +impl RemovalOutcome { + /// Record an item's description + status. + fn record(&mut self, description: String, status: ItemStatus) { + self.results.push((description, status)); + } + + /// Number of items that completed. + pub(crate) fn done_count(&self) -> usize { + self.results + .iter() + .filter(|(_, status)| *status == ItemStatus::Done) + .count() + } + + /// Number of items that failed. + pub(crate) fn failed_count(&self) -> usize { + self.results + .iter() + .filter(|(_, status)| matches!(status, ItemStatus::Failed(_))) + .count() + } + + /// Whether every item completed. + pub(crate) fn all_done(&self) -> bool { + self.failed_count() == 0 + } +} + +/// Execute `plan` in order against `effects`, recording each item's outcome. +/// Best-effort: a failing item is recorded and the walk continues. +pub(crate) fn execute(plan: &RemovalPlan, effects: &mut dyn Effects) -> RemovalOutcome { + let mut outcome = RemovalOutcome::default(); + for item in plan.items() { + let description = item.target.describe(); + let status = match dispatch(&item.target, effects) { + Ok(()) => ItemStatus::Done, + Err(err) => ItemStatus::Failed(format!("{err:#}")), + }; + outcome.record(description, status); + } + outcome +} + +/// Route one target to the matching [`Effects`] call. +fn dispatch(target: &PlanTarget, effects: &mut dyn Effects) -> Result<()> { + match target { + PlanTarget::StopProcess { component, pid } => effects.stop_process(component, *pid), + PlanTarget::RemoveService { service } => effects.remove_service(service), + PlanTarget::DeleteBinaries { dir, stems } => effects.delete_binaries(dir, stems), + PlanTarget::DelegateWinget { + package_id, scope, .. + } => effects.delegate_winget(package_id, *scope), + PlanTarget::DeleteDir { path, .. } => effects.remove_dir(path), + PlanTarget::RemovePathEntry { dir } => effects.remove_path_entry(dir), + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use anyhow::{Result, anyhow}; + + use super::{Effects, ItemStatus, execute}; + use crate::commands::uninstall::args::UninstallArgs; + use crate::commands::uninstall::inventory::{ + ArtifactDir, ArtifactKind, BrokerServiceState, Inventory, + }; + use crate::commands::uninstall::plan::build_plan; + use crate::commands::update::model::{ + BinaryInfo, Channel, Component, DetectionReport, InstallRoot, RunningProcess, Scope, + }; + + /// Records the call sequence; never touches the filesystem. `fail_dir` + /// makes the matching `remove_dir`/`delete_binaries` call fail, to + /// exercise the best-effort path. + #[derive(Default)] + struct RecordingEffects { + calls: Vec, + fail_marker: Option, + } + + impl Effects for RecordingEffects { + fn stop_process(&mut self, component: &str, pid: u32) -> Result<()> { + self.calls.push(format!("stop_process:{component}:{pid}")); + Ok(()) + } + fn remove_service(&mut self, service: &str) -> Result<()> { + self.calls.push(format!("remove_service:{service}")); + Ok(()) + } + fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> { + self.calls + .push(format!("delete_binaries:{}:{}", dir.display(), stems.len())); + Ok(()) + } + fn delegate_winget(&mut self, package_id: &str, _scope: Scope) -> Result<()> { + self.calls.push(format!("delegate_winget:{package_id}")); + Ok(()) + } + fn remove_dir(&mut self, path: &Path) -> Result<()> { + let shown = path.display().to_string(); + self.calls.push(format!("remove_dir:{shown}")); + if self.fail_marker.as_deref() == Some(shown.as_str()) { + return Err(anyhow!("simulated permission denied")); + } + Ok(()) + } + fn remove_path_entry(&mut self, dir: &Path) -> Result<()> { + self.calls + .push(format!("remove_path_entry:{}", dir.display())); + Ok(()) + } + } + + fn full_plan() -> crate::commands::uninstall::plan::RemovalPlan { + let report = DetectionReport { + roots: vec![InstallRoot { + dir: PathBuf::from("/opt/uffs"), + channel: Channel::Unmanaged, + scope: Scope::User, + anchored_by: Vec::new(), + binaries: vec![BinaryInfo { + name: "uffs".to_owned(), + version: None, + }], + }], + running: vec![RunningProcess { + component: Component::Daemon, + pid: 7, + image_path: None, + command_line: None, + version: None, + }], + }; + let inventory = Inventory { + dirs: vec![ArtifactDir { + kind: ArtifactKind::Cache, + path: PathBuf::from("/x/cache"), + exists: true, + size_bytes: 1, + }], + broker_service: BrokerServiceState::Absent, + }; + build_plan(&report, &inventory, &UninstallArgs::default(), &[]) + } + + #[test] + fn executes_every_item_in_group_order() { + let plan = full_plan(); + let mut effects = RecordingEffects::default(); + let outcome = execute(&plan, &mut effects); + // Processes (stop) precede Binaries (delete), which precede Data dirs. + assert_eq!(effects.calls, vec![ + "stop_process:daemon:7".to_owned(), + "delete_binaries:/opt/uffs:1".to_owned(), + "remove_dir:/x/cache".to_owned(), + ]); + assert!(outcome.all_done()); + assert_eq!(outcome.done_count(), 3); + } + + #[test] + fn a_failing_item_is_recorded_and_the_rest_continue() { + let plan = full_plan(); + let mut effects = RecordingEffects { + fail_marker: Some("/x/cache".to_owned()), + ..RecordingEffects::default() + }; + let outcome = execute(&plan, &mut effects); + // All three were attempted; the cache dir failed, the other two done. + assert_eq!(effects.calls.len(), 3); + assert_eq!(outcome.failed_count(), 1); + assert_eq!(outcome.done_count(), 2); + assert!(!outcome.all_done()); + let failed = outcome + .results + .iter() + .find(|(_, status)| matches!(status, ItemStatus::Failed(_))) + .expect("a failed item"); + assert!(failed.0.contains("cache")); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs new file mode 100644 index 000000000..00ae2f7f9 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Rendering of the `uffs --uninstall` analysis (task U-12): the binary +//! resolution table + the artifact inventory, in human form and as `--json`. +//! The removal plan is layered on in later milestones. + +use serde_json::{Value, json}; + +use super::inventory::Inventory; +use super::plan::RemovalPlan; +use super::remove::{ItemStatus, RemovalOutcome}; +use super::resolve_order::{ResolutionState, StemResolution}; + +/// Print the discovered-binary resolution table: for each stem, every copy in +/// OS search order, with the one a bare command runs flagged ACTIVE. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_resolution_table(stems: &[StemResolution]) { + if stems.is_empty() { + println!("No UFFS binaries found in any install root or on PATH."); + return; + } + println!("Discovered UFFS binaries (the copy a bare command runs is ACTIVE):\n"); + for stem in stems { + println!("{}:", stem.stem); + for copy in &stem.copies { + let state = match copy.state { + ResolutionState::Active => "ACTIVE", + ResolutionState::Shadowed if copy.on_search_path => "shadowed", + ResolutionState::Shadowed => "off-path", + }; + let version = copy.version.as_deref().unwrap_or("-"); + println!( + " {state:<8} {version:<9} {channel:<9} {scope:<7} {dir}", + channel = copy.channel.label(), + scope = copy.scope.label(), + dir = copy.dir.display(), + ); + } + } +} + +/// Print the non-binary artifact inventory (data / cache / legacy / config) +/// plus the broker-service state. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_inventory(inventory: &Inventory) { + println!("\nData / cache / config:"); + for dir in &inventory.dirs { + let size = if dir.exists { + human_bytes(dir.size_bytes) + } else { + "absent".to_owned() + }; + println!( + " {kind:<13} {size:<10} {path}", + kind = dir.kind.label(), + path = dir.path.display(), + ); + } + println!( + "\nBroker service ({name}): {state}", + name = uffs_broker_protocol::SERVICE_NAME, + state = inventory.broker_service.label(), + ); +} + +/// Print the ordered removal plan (consent surface, U-21). Items are numbered +/// across groups; ones needing Administrator are flagged. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_plan(plan: &RemovalPlan) { + if plan.is_empty() { + println!("\nNothing to remove: no UFFS install or artifacts were found."); + return; + } + println!("\nThe following will be PERMANENTLY removed (no recovery):"); + let mut index: usize = 1; + for group in &plan.groups { + println!("\n {}", group.title); + for item in &group.items { + let elevated = if item.needs_elevation { + " (needs Administrator)" + } else { + "" + }; + println!( + " [{index}] {desc}{elevated}", + desc = item.target.describe() + ); + index = index.saturating_add(1); + } + } + println!( + "\nReclaims ~{} across {} item(s).", + human_bytes(plan.total_bytes()), + plan.item_count(), + ); +} + +/// Print the elevation refusal (U-30): the items that need Administrator and +/// the re-run hint. Goes to stderr; the caller exits non-zero without any +/// effect. +#[expect(clippy::print_stderr, reason = "CLI user-facing error")] +pub(crate) fn print_elevation_refusal(plan: &RemovalPlan) { + eprintln!("\nThis uninstall includes items that require Administrator:"); + for group in &plan.groups { + for item in &group.items { + if item.needs_elevation { + eprintln!(" - {}", item.target.describe()); + } + } + } + eprintln!( + "\nRe-run with elevated privileges (sudo on Linux/macOS, an elevated \ + shell on Windows):\n uffs --uninstall" + ); +} + +/// Print stray UFFS-named files the deep sweep found outside the known roots. +/// These are listed for review only, never auto-removed. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_strays(strays: &[std::path::PathBuf]) { + if strays.is_empty() { + return; + } + println!( + "\nStray UFFS-named files found elsewhere (NOT removed — review and delete\n\ + manually if they are unwanted; one may be a copy you placed yourself):" + ); + for path in strays { + println!(" {}", path.display()); + } +} + +/// Note that a prior uninstall was interrupted and this run completes it. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_resumed_note() { + println!( + "A previous uninstall did not finish. Removal is idempotent, so this run \ + will complete it.\n" + ); +} + +/// Warn that the in-progress journal marker could not be written/cleared. +#[expect(clippy::print_stderr, reason = "CLI user-facing error")] +pub(crate) fn print_journal_warning(error: &anyhow::Error) { + eprintln!("note: uninstall progress marker could not be updated ({error:#})."); +} + +/// Warn that the running self-binary could not be scheduled for deletion. +#[expect(clippy::print_stderr, reason = "CLI user-facing error")] +pub(crate) fn print_self_delete_warning(error: &anyhow::Error) { + eprintln!( + "\nCould not schedule deletion of the running uffs binary ({error:#}).\n\ + Delete it manually once this process has exited." + ); +} + +/// Print the post-removal verification: clean, or the locations that survived. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_verification(remaining: &[std::path::PathBuf]) { + if remaining.is_empty() { + println!("\nVerified: all targeted UFFS locations are gone."); + return; + } + println!( + "\nVerification: {} location(s) still present (a reboot may be pending, or \ + elevation/sudo is needed):", + remaining.len() + ); + for path in remaining { + println!(" {}", path.display()); + } +} + +/// Print the outcome of a removal run: counts, any failures, and a retry hint. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_outcome(outcome: &RemovalOutcome) { + println!( + "\nRemoval finished: {} removed, {} failed.", + outcome.done_count(), + outcome.failed_count(), + ); + for (description, status) in &outcome.results { + if let ItemStatus::Failed(error) = status { + println!(" FAILED {description} ({error})"); + } + } + if !outcome.all_done() { + println!( + "\nSome items could not be removed. Retry with elevated privileges \ + (sudo on Linux/macOS, an elevated shell on Windows)." + ); + } +} + +/// Emit the full analysis (binaries + artifacts + broker state + plan) as JSON. +#[expect(clippy::print_stdout, reason = "machine-readable CLI output")] +pub(crate) fn print_json(resolution: &[StemResolution], inventory: &Inventory, plan: &RemovalPlan) { + let value = analysis_json(resolution, inventory, plan); + let text = serde_json::to_string_pretty(&value) + .unwrap_or_else(|_| "{\"error\":\"serialize\"}".to_owned()); + println!("{text}"); +} + +/// Build the plan JSON value (pure). +fn plan_json(plan: &RemovalPlan) -> Value { + let groups: Vec = plan + .groups + .iter() + .map(|group| { + let items: Vec = group + .items + .iter() + .map(|item| { + json!({ + "action": item.target.action_label(), + "description": item.target.describe(), + "needs_elevation": item.needs_elevation, + "bytes": item.bytes, + }) + }) + .collect(); + json!({ "title": group.title, "items": items }) + }) + .collect(); + json!({ + "total_bytes": plan.total_bytes(), + "item_count": plan.item_count(), + "requires_elevation": plan.requires_elevation(), + "groups": groups, + }) +} + +/// Build the analysis JSON value (pure; unit-testable without IO). +fn analysis_json( + resolution: &[StemResolution], + inventory: &Inventory, + plan: &RemovalPlan, +) -> Value { + let binaries: Vec = resolution + .iter() + .map(|stem| { + let copies: Vec = stem + .copies + .iter() + .map(|copy| { + json!({ + "state": match copy.state { + ResolutionState::Active => "active", + ResolutionState::Shadowed => "shadowed", + }, + "on_search_path": copy.on_search_path, + "version": copy.version, + "channel": copy.channel.label(), + "scope": copy.scope.label(), + "dir": copy.dir.display().to_string(), + }) + }) + .collect(); + json!({ "stem": stem.stem, "copies": copies }) + }) + .collect(); + let artifacts: Vec = inventory + .dirs + .iter() + .map(|dir| { + json!({ + "kind": dir.kind.label(), + "path": dir.path.display().to_string(), + "exists": dir.exists, + "size_bytes": dir.size_bytes, + }) + }) + .collect(); + json!({ + "binaries": binaries, + "artifacts": artifacts, + "broker_service": inventory.broker_service.label(), + "plan": plan_json(plan), + }) +} + +/// Format a byte count for humans using integer math (no float casts, which the +/// workspace `cast_precision_loss` lint forbids). One decimal place. +fn human_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = 1024 * 1024; + const GIB: u64 = 1024 * 1024 * 1024; + let (unit, label) = if bytes >= GIB { + (GIB, "GB") + } else if bytes >= MIB { + (MIB, "MB") + } else if bytes >= KIB { + (KIB, "KB") + } else { + return format!("{bytes} B"); + }; + let whole = bytes / unit; + let frac = (bytes % unit).saturating_mul(10) / unit; + format!("{whole}.{frac} {label}") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::super::inventory::{ArtifactDir, ArtifactKind, BrokerServiceState, Inventory}; + use super::super::plan::RemovalPlan; + use super::{Value, analysis_json, human_bytes}; + + #[test] + fn human_bytes_picks_units() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(512), "512 B"); + assert_eq!(human_bytes(1024), "1.0 KB"); + assert_eq!(human_bytes(1536), "1.5 KB"); + assert_eq!(human_bytes(1024 * 1024), "1.0 MB"); + assert_eq!( + human_bytes(1024 * 1024 * 1024 + 512 * 1024 * 1024), + "1.5 GB" + ); + } + + #[test] + fn json_has_top_level_sections() { + let inventory = Inventory { + dirs: vec![ArtifactDir { + kind: ArtifactKind::Cache, + path: PathBuf::from("/x/cache"), + exists: true, + size_bytes: 10, + }], + broker_service: BrokerServiceState::Absent, + }; + let value = analysis_json(&[], &inventory, &RemovalPlan::default()); + assert!(value.get("binaries").is_some()); + assert!(value.get("artifacts").is_some()); + assert!(value.get("plan").is_some()); + assert_eq!( + value.get("broker_service").and_then(Value::as_str), + Some("absent") + ); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/resolve_order.rs b/crates/uffs-cli/src/commands/uninstall/resolve_order.rs new file mode 100644 index 000000000..966fb4043 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/resolve_order.rs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Resolution-order analysis for `uffs --uninstall` (task U-10 of +//! `docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md`). +//! +//! Pure: given the discovered copies of a binary stem and the ordered list of +//! directories the OS searches for an executable (design §5.1: the running +//! image's dir, the system dirs, the current dir, then PATH in order), return +//! the copies sorted by which one a bare `uffs ` would actually run, with +//! the first reachable copy marked ACTIVE and the rest SHADOWED. Building the +//! search-dir list is the caller's (impure) job; ordering is pure here. + +use std::path::{Path, PathBuf}; + +use crate::commands::update::model::{Channel, Scope}; + +/// Standing of a discovered copy in the OS executable search order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResolutionState { + /// The copy a bare `uffs ` resolves to (first reachable on the path). + Active, + /// A copy that exists but is shadowed by an earlier one, or is not on the + /// search path at all. + Shadowed, +} + +/// A discovered copy of one binary stem, before ordering. +#[derive(Debug, Clone)] +pub(crate) struct Candidate { + /// Logical stem (e.g. `uffs`), without the platform `.exe` suffix. + pub(crate) stem: String, + /// On-disk version, if it could be read. + pub(crate) version: Option, + /// Channel that placed the copy. + pub(crate) channel: Channel, + /// Install scope of the copy's root. + pub(crate) scope: Scope, + /// The directory the copy lives in. + pub(crate) dir: PathBuf, +} + +/// A copy after ordering, tagged with its resolution standing. +#[derive(Debug, Clone)] +pub(crate) struct ResolvedBinary { + /// Active or shadowed. + pub(crate) state: ResolutionState, + /// On-disk version, if it could be read. + pub(crate) version: Option, + /// Channel that placed the copy. + pub(crate) channel: Channel, + /// Install scope of the copy's root. + pub(crate) scope: Scope, + /// The directory the copy lives in. + pub(crate) dir: PathBuf, + /// Whether the copy's dir is on the executable search path at all. + pub(crate) on_search_path: bool, +} + +/// All discovered copies of one stem, ordered by resolution precedence. +#[derive(Debug, Clone)] +pub(crate) struct StemResolution { + /// Logical stem (e.g. `uffs`). + pub(crate) stem: String, + /// The copies, ACTIVE first when one is reachable, then shadowed. + pub(crate) copies: Vec, +} + +/// Compare two paths for equality, case-insensitively (Windows file systems are +/// case-insensitive and PATH entries vary in case). +fn paths_equal_ignore_case(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + +/// Rank of `dir` within `search_dirs` (lower = earlier). Directories not on the +/// search path return `None` (sorted after all reachable copies). +fn rank_of(dir: &Path, search_dirs: &[PathBuf]) -> Option { + search_dirs + .iter() + .position(|candidate| paths_equal_ignore_case(candidate, dir)) +} + +/// Order `candidates` (all the same stem) by search precedence and tag the +/// first reachable copy ACTIVE, the rest SHADOWED. Stable: off-path copies (and +/// ties) fall back to a path-string compare so output is deterministic. +pub(crate) fn resolve_stem( + candidates: Vec, + search_dirs: &[PathBuf], +) -> Vec { + let mut ranked: Vec<(Option, Candidate)> = candidates + .into_iter() + .map(|candidate| (rank_of(&candidate.dir, search_dirs), candidate)) + .collect(); + ranked.sort_by(|left, right| match (left.0, right.0) { + (Some(rank_l), Some(rank_r)) => rank_l.cmp(&rank_r), + (Some(_), None) => core::cmp::Ordering::Less, + (None, Some(_)) => core::cmp::Ordering::Greater, + (None, None) => left.1.dir.cmp(&right.1.dir), + }); + let mut active_assigned = false; + ranked + .into_iter() + .map(|(rank, candidate)| { + let on_search_path = rank.is_some(); + let state = if on_search_path && !active_assigned { + active_assigned = true; + ResolutionState::Active + } else { + ResolutionState::Shadowed + }; + ResolvedBinary { + state, + version: candidate.version, + channel: candidate.channel, + scope: candidate.scope, + dir: candidate.dir, + on_search_path, + } + }) + .collect() +} + +/// Group `candidates` by stem (sorted) and resolve each group. Only a handful +/// of binary stems exist, so the per-stem filter is trivial and avoids pulling +/// in a map type. +pub(crate) fn group_and_resolve( + candidates: &[Candidate], + search_dirs: &[PathBuf], +) -> Vec { + let mut stems: Vec = candidates + .iter() + .map(|candidate| candidate.stem.clone()) + .collect(); + stems.sort_unstable(); + stems.dedup(); + stems + .into_iter() + .map(|stem| { + let group: Vec = candidates + .iter() + .filter(|candidate| candidate.stem == stem) + .cloned() + .collect(); + StemResolution { + stem, + copies: resolve_stem(group, search_dirs), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{Candidate, ResolutionState, group_and_resolve, resolve_stem}; + use crate::commands::update::model::{Channel, Scope}; + + fn candidate(stem: &str, dir: &str) -> Candidate { + Candidate { + stem: stem.to_owned(), + version: None, + channel: Channel::Unmanaged, + scope: Scope::User, + dir: PathBuf::from(dir), + } + } + + #[test] + fn first_on_path_is_active_rest_shadowed() { + let candidates = vec![ + candidate("uffs", r"C:\src\target\release"), + candidate("uffs", r"C:\Users\me\bin"), + ]; + let search = vec![ + PathBuf::from(r"C:\Users\me\bin"), + PathBuf::from(r"C:\src\target\release"), + ]; + let out = resolve_stem(candidates, &search); + let first = out.first().expect("a first copy"); + let second = out.get(1).expect("a second copy"); + assert_eq!(first.state, ResolutionState::Active); + assert_eq!(first.dir, PathBuf::from(r"C:\Users\me\bin")); + assert_eq!(second.state, ResolutionState::Shadowed); + } + + #[test] + fn off_path_copies_sort_last_and_are_shadowed() { + let candidates = vec![ + candidate("uffs", r"C:\Downloads"), + candidate("uffs", r"C:\Users\me\bin"), + ]; + let search = vec![PathBuf::from(r"C:\Users\me\bin")]; + let out = resolve_stem(candidates, &search); + let first = out.first().expect("a first copy"); + let second = out.get(1).expect("a second copy"); + assert_eq!(first.dir, PathBuf::from(r"C:\Users\me\bin")); + assert_eq!(first.state, ResolutionState::Active); + assert!(first.on_search_path); + assert_eq!(second.state, ResolutionState::Shadowed); + assert!(!second.on_search_path); + } + + #[test] + fn case_insensitive_path_match() { + let out = resolve_stem(vec![candidate("uffs", r"C:\Users\Me\Bin")], &[ + PathBuf::from(r"c:\users\me\bin"), + ]); + assert_eq!(out.first().expect("a copy").state, ResolutionState::Active); + } + + #[test] + fn no_active_when_nothing_on_path() { + let out = resolve_stem(vec![candidate("uffs", r"C:\Downloads")], &[]); + assert_eq!( + out.first().expect("a copy").state, + ResolutionState::Shadowed + ); + } + + #[test] + fn empty_input_empty_output() { + assert!(resolve_stem(Vec::new(), &[]).is_empty()); + } + + #[test] + fn group_and_resolve_groups_by_stem_sorted() { + let candidates = vec![ + candidate("uffsd", r"C:\bin"), + candidate("uffs", r"C:\bin"), + candidate("uffs", r"C:\other"), + ]; + let groups = group_and_resolve(&candidates, &[PathBuf::from(r"C:\bin")]); + assert_eq!(groups.len(), 2); + assert_eq!(groups.first().expect("group").stem, "uffs"); + assert_eq!(groups.get(1).expect("group").stem, "uffsd"); + assert_eq!(groups.first().expect("group").copies.len(), 2); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs new file mode 100644 index 000000000..3cbaee871 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Deep sweep for `uffs --uninstall` (task U-70/U-71): use UFFS's own search to +//! find stray family files anywhere on the indexed drives, beyond the known +//! install roots. Strays are **reported for review, never auto-removed** — a +//! `uffs.exe` under `Downloads` might be the user's own copy (design §8). +//! +//! The dedup logic is pure + unit-tested against a fake [`Search`]; the live +//! backend ([`DaemonSearch`]) is best-effort (no daemon ⇒ no hits, never a +//! hard failure). + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde_json::Value; + +/// Family-file name patterns the sweep searches for. +const STRAY_PATTERNS: &[&str] = &[ + "uffs.exe", + "uffsd.exe", + "uffsmcp.exe", + "uffs-broker.exe", + "uffs-update.exe", + "uffs-mft.exe", + "uffs-tui*.exe", + "uffs-gui*.exe", + "*_compact.uffs", + "*_usn.cursor", +]; + +/// A search backend, injected so the dedup logic is testable without a daemon. +pub(crate) trait Search { + /// Absolute paths matching `pattern` (best-effort; empty on any failure). + fn find(&mut self, pattern: &str) -> Result>; +} + +/// Find stray family files across every pattern, dropping any hit already under +/// a directory the plan handles. Sorted + de-duplicated. +pub(crate) fn find_strays(search: &mut dyn Search, known_dirs: &[PathBuf]) -> Result> { + let mut strays: Vec = Vec::new(); + for pattern in STRAY_PATTERNS { + for hit in search.find(pattern)? { + if !is_under_any(&hit, known_dirs) { + strays.push(hit); + } + } + } + strays.sort(); + strays.dedup(); + Ok(strays) +} + +/// Whether `path` is `dir` or lives beneath it (case-insensitive, separator +/// aware so `/opt/uffs` does not spuriously match `/opt/uffs-other`). +fn is_under_any(path: &Path, dirs: &[PathBuf]) -> bool { + let lower = path.to_string_lossy().to_ascii_lowercase(); + dirs.iter().any(|dir| { + let base = dir.to_string_lossy().to_ascii_lowercase(); + lower == base + || lower.starts_with(&format!("{base}/")) + || lower.starts_with(&format!("{base}\\")) + }) +} + +/// Live search backend over the resident daemon. Best-effort: no daemon, or any +/// RPC error, yields no hits rather than failing the uninstall. +pub(crate) struct DaemonSearch; + +impl Search for DaemonSearch { + fn find(&mut self, pattern: &str) -> Result> { + let Ok(mut client) = uffs_client::connect_sync::UffsClientSync::connect_raw() else { + return Ok(Vec::new()); + }; + let args = vec![ + pattern.to_owned(), + "--files-only".to_owned(), + "--limit".to_owned(), + "1000".to_owned(), + ]; + let Ok(value) = client.search_cli_raw(&args) else { + return Ok(Vec::new()); + }; + Ok(extract_paths(&value)) + } +} + +/// Pull every `"path"` string out of a search-result JSON value (defensive: the +/// shape varies, so walk it recursively). +fn extract_paths(value: &Value) -> Vec { + let mut out = Vec::new(); + collect_paths(value, &mut out); + out +} + +/// Recursive helper for [`extract_paths`]. +fn collect_paths(value: &Value, out: &mut Vec) { + match value { + Value::Object(map) => { + if let Some(Value::String(path)) = map.get("path") { + out.push(PathBuf::from(path)); + } + for child in map.values() { + collect_paths(child, out); + } + } + Value::Array(items) => { + for item in items { + collect_paths(item, out); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use anyhow::Result; + + use super::{Search, extract_paths, find_strays}; + + /// Returns the same hits for every pattern (the dedup must collapse them). + struct FakeSearch(Vec); + + impl Search for FakeSearch { + fn find(&mut self, _pattern: &str) -> Result> { + Ok(self.0.clone()) + } + } + + #[test] + fn hits_under_known_dirs_are_filtered_and_deduped() { + let mut search = FakeSearch(vec![ + PathBuf::from("/opt/uffs/uffs"), + PathBuf::from("/home/me/Downloads/uffs.exe"), + ]); + let known = [PathBuf::from("/opt/uffs")]; + let strays = find_strays(&mut search, &known).unwrap(); + // The /opt/uffs hit is already planned; only the Downloads stray remains, + // de-duplicated despite being returned once per pattern. + assert_eq!(strays.len(), 1); + assert_eq!( + strays.first().expect("a stray"), + &PathBuf::from("/home/me/Downloads/uffs.exe") + ); + } + + #[test] + fn sibling_prefix_is_not_treated_as_under() { + let mut search = FakeSearch(vec![PathBuf::from("/opt/uffs-other/uffs.exe")]); + let known = [PathBuf::from("/opt/uffs")]; + let strays = find_strays(&mut search, &known).unwrap(); + assert_eq!(strays.len(), 1, "sibling dir must not be filtered"); + } + + #[test] + fn extracts_path_fields_recursively() { + let value = serde_json::json!({ + "rows": [{ "path": "/a/uffs.exe" }, { "name": "x", "path": "/b/uffsd.exe" }], + }); + let paths = extract_paths(&value); + assert_eq!(paths.len(), 2); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/verify.rs b/crates/uffs-cli/src/commands/uninstall/verify.rs new file mode 100644 index 000000000..719662a89 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/verify.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Post-removal verification for `uffs --uninstall` (task U-81). +//! +//! After the executor runs (and the daemon is stopped), confirm the targeted +//! locations are actually gone by re-stat-ing them. Daemon-free, so it works +//! even though the search service has been removed. Locations that are +//! reboot-deferred (a locked self-binary) are excluded by the caller. + +use std::path::PathBuf; + +/// Return the subset of `paths` that still exist on disk (a non-empty result +/// means removal did not fully complete — usually a permission issue or a +/// reboot-deferred lock). +pub(crate) fn still_present(paths: &[PathBuf]) -> Vec { + paths + .iter() + .filter(|path| path.try_exists().unwrap_or(false)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::still_present; + + #[test] + fn reports_only_paths_that_exist() { + let here = std::env::temp_dir(); + let gone = PathBuf::from("/nonexistent/uffs-verify-probe-xyz"); + let remaining = still_present(&[here.clone(), gone]); + assert_eq!(remaining, vec![here]); + } + + #[test] + fn empty_input_is_clean() { + assert!(still_present(&[]).is_empty()); + } +} diff --git a/crates/uffs-cli/src/commands/update/mod.rs b/crates/uffs-cli/src/commands/update/mod.rs index 95ad4789a..ae9d51ac5 100644 --- a/crates/uffs-cli/src/commands/update/mod.rs +++ b/crates/uffs-cli/src/commands/update/mod.rs @@ -22,11 +22,11 @@ mod acquire; mod apply; -mod binaries; +pub(crate) mod binaries; mod channel; mod doctor; -mod model; -mod procinfo; +pub(crate) mod model; +pub(crate) mod procinfo; mod report; mod self_heal; mod snapshot; @@ -354,7 +354,7 @@ fn write_and_report_snapshot(report: &DetectionReport) { /// Phase A orchestration: anchors → roots → channel + versions, plus the /// running-process map. -fn detect() -> DetectionReport { +pub(crate) fn detect() -> DetectionReport { let mut roots: Vec = Vec::new(); let mut running: Vec = Vec::new(); diff --git a/crates/uffs-cli/src/dispatch.rs b/crates/uffs-cli/src/dispatch.rs index 19bc67dac..4c8c93f2b 100644 --- a/crates/uffs-cli/src/dispatch.rs +++ b/crates/uffs-cli/src/dispatch.rs @@ -32,6 +32,8 @@ pub(crate) enum Command { Mcp, /// `--update [action]`. Update, + /// `--uninstall [flags]`. + Uninstall, /// `--status`. Status, } @@ -47,6 +49,7 @@ impl Command { "--daemon" => Self::Daemon, "--mcp" => Self::Mcp, "--update" => Self::Update, + "--uninstall" => Self::Uninstall, "--status" => Self::Status, _ => return None, }) @@ -64,6 +67,7 @@ const COMMAND_TOKENS: &[&str] = &[ "--daemon", "--mcp", "--update", + "--uninstall", "--status", ]; @@ -104,6 +108,7 @@ pub(crate) fn dispatch_command(command: Command, args: &[String]) -> Result<()> Command::Daemon => crate::run_daemon(args), Command::Mcp => commands::mcp_mgmt::mcp_from_args(args), Command::Update => commands::update::run_update(args), + Command::Uninstall => commands::uninstall::run_uninstall(args), Command::Status => { run_status(args); Ok(()) @@ -127,6 +132,7 @@ mod tests { #[test] fn command_tokens_resolve() { assert_eq!(Command::from_token("--update"), Some(Command::Update)); + assert_eq!(Command::from_token("--uninstall"), Some(Command::Uninstall)); assert_eq!(Command::from_token("--daemon"), Some(Command::Daemon)); assert_eq!(Command::from_token("--mcp"), Some(Command::Mcp)); assert_eq!(Command::from_token("--stats"), Some(Command::Stats)); diff --git a/crates/uffs-mft/src/platform.rs b/crates/uffs-mft/src/platform.rs index 24ab36b1f..7a9bdcfc0 100644 --- a/crates/uffs-mft/src/platform.rs +++ b/crates/uffs-mft/src/platform.rs @@ -60,6 +60,10 @@ pub use system::DriveType; // caller against the running daemon's owner (its PID-file uid). #[cfg(unix)] pub use system::current_euid; +// Unix: POSIX W_OK writability probe — lets `uffs --uninstall` flag a +// root-owned binary root before it tries to delete it. +#[cfg(unix)] +pub use system::dir_user_writable; // Elevation check — available on all platforms (Windows: UAC token check; // Unix: geteuid() == 0). Both the daemon CLI gate and uffs-daemon use this. pub use system::is_elevated; diff --git a/crates/uffs-mft/src/platform/system.rs b/crates/uffs-mft/src/platform/system.rs index 4e4e79f63..54523a2c8 100644 --- a/crates/uffs-mft/src/platform/system.rs +++ b/crates/uffs-mft/src/platform/system.rs @@ -138,6 +138,31 @@ pub fn current_euid() -> u32 { unsafe { libc::geteuid() } } +/// Unix: whether the calling user can create or remove entries inside `dir`, +/// via a POSIX `access(2)` `W_OK` probe. +/// +/// Used by `uffs --uninstall` to decide whether a binary root is removable +/// without `sudo` before it tries: a user-owned root (`~/bin`, `~/.cargo/bin`, +/// a dev build) is writable; a root-owned one (`/usr/local/bin`) is not. A +/// missing or unreadable `dir` returns `false` (conservative: flag escalation). +#[cfg(unix)] +#[must_use] +#[expect( + unsafe_code, + reason = "FFI: POSIX access() — the libc binding is unsafe" +)] +pub fn dir_user_writable(dir: &std::path::Path) -> bool { + use std::os::unix::ffi::OsStrExt as _; + + let Ok(c_dir) = alloc::ffi::CString::new(dir.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: `c_dir` is a valid NUL-terminated C string that outlives the + // call; `access()` only reads through the pointer and returns 0 when the + // directory is writable by the caller. + unsafe { libc::access(c_dir.as_ptr(), libc::W_OK) == 0 } +} + /// Returns the path to the volume root (e.g., "C:\"). #[cfg(windows)] #[must_use] diff --git a/docs/user-manual/index.md b/docs/user-manual/index.md index aadb3a1e4..9c0b7bfba 100644 --- a/docs/user-manual/index.md +++ b/docs/user-manual/index.md @@ -73,6 +73,7 @@ Start here and follow the arrows. Each page builds on the previous one. |------|---------------| | [Installation](installation.md) | Build from source, platform requirements, PATH setup | | [Updating](updating.md) | Self-update: `uffs --update`, version pinning/rollback, doctor & repair | +| [Uninstalling](uninstall.md) | Full removal: `uffs --uninstall`, dry-run, elevation, WinGet delegation | | [Getting Started](getting-started.md) | First search, understanding output, 5-minute tutorial | ### Core Usage diff --git a/docs/user-manual/uninstall.md b/docs/user-manual/uninstall.md new file mode 100644 index 000000000..3e92bc0d7 --- /dev/null +++ b/docs/user-manual/uninstall.md @@ -0,0 +1,126 @@ +# Uninstalling UFFS (`uffs --uninstall`) + +`uffs --uninstall` removes UFFS and **all of its data** from the machine in one +guided, reversible-until-you-confirm flow: it analyzes what is installed, shows +you an itemized plan, asks for confirmation, then removes everything in a safe +order and verifies the result. + +```bash +uffs --uninstall # analyze, show the plan, confirm, then remove +uffs --uninstall --dry-run # show the analysis + plan and change NOTHING +``` + +> Nothing is removed without your explicit `y` at the prompt (or `--yes`). +> `--dry-run` is always safe and never needs elevation. + +--- + +## What it does, step by step + +1. **Analyzes** the install. Every UFFS binary is listed **in the order the OS + resolves them**, so the copy a bare `uffs` actually runs is flagged `ACTIVE` + and any shadowed / duplicate copies are shown. This explains version skew + (e.g. a WinGet copy shadowed by a hand-placed one). +2. **Inventories** every non-binary artifact with its size: the data dir, the + encrypted cache (per-drive indexes + USN cursors), the legacy cache, the + per-user config, and the Windows broker service. +3. **Deep sweep** (UFFS searching for itself): asks the running daemon for any + stray `uffs*` files elsewhere on your drives. Strays are **listed for review, + never auto-removed** (one might be a copy you placed in `Downloads`). +4. **Plan + consent.** Prints an itemized, ordered removal plan with the total + space reclaimed, then prompts. `--dry-run` stops here. +5. **Removes** in a safe order: stop the daemon / MCP / broker service, delete + binaries, purge data / cache / config, clean PATH, then **verify** that the + targeted locations are gone. + +--- + +## Elevation: do you need `sudo` / Administrator? + +UFFS only asks for elevation when a removal genuinely requires it, and it +**refuses up front** (before touching anything) if the run is not elevated: + +| Platform | When elevation is needed | +|---|---| +| **macOS / Linux** | Only if a binary lives somewhere your user cannot write (e.g. a root-owned `/usr/local/bin`). A normal user install (`~/bin`, `~/.cargo/bin`, a dev build) needs **no `sudo`** — verified with a real `access(W_OK)` writability check. | +| **Windows** | Removing the `UffsAccessBroker` service or a machine-scope install under `%PROGRAMFILES%` needs an **elevated** shell. A per-user install does not. | + +If elevation is required and missing, the command lists exactly which items need +it and exits without changing anything: + +``` +This uninstall includes items that require Administrator: + - Stop + delete service UffsAccessBroker +Re-run with elevated privileges (sudo on Linux/macOS, an elevated shell on Windows): + uffs --uninstall +``` + +--- + +## Channel-aware: WinGet is delegated, never hand-deleted + +If UFFS was installed via **WinGet**, that root is handed to +`winget uninstall SkyLLC.UFFS` rather than deleted by hand, so WinGet's own +state stays consistent. Manual (GitHub-release) and dev-build installs are +removed directly. + +--- + +## Flags + +| Flag | Effect | +|------|--------| +| `--dry-run` | Show the analysis + plan and change nothing (always safe). | +| `--yes`, `-y` | Skip the confirmation prompt (for scripted removal). | +| `--keep-config` | Remove binaries + caches but **keep** the settings/config dir. | +| `--no-deep-sweep` | Skip the cross-drive search for stray UFFS files. | +| `--no-path` | Do not touch PATH (a manual hint is printed instead). | +| `--scope ` | Restrict to a single scope (default `all`). | +| `--json` | Emit the full analysis + plan as JSON (for tooling / installers). | +| `--help`, `-h` | Show usage. | + +--- + +## What gets removed + +- **Binaries:** `uffs`, `uffsd`, `uffsmcp`, `uffs-update`, `uffs-mft` (and + `uffs-broker` on Windows), in every discovered install root, plus any + `uffs-tui` / `uffs-gui` left from earlier installs. +- **Service (Windows):** the `UffsAccessBroker` LocalSystem service + its + registry key. +- **Data dir:** `%LOCALAPPDATA%\uffs\` (daemon pid + state, the update working + dir). macOS: `~/Library/Application Support/uffs`; Linux: the XDG data dir. +- **Cache:** `%LOCALAPPDATA%\uffs\cache\` (per-drive compact indexes + USN + cursors) and the legacy `%TEMP%\uffs_index_cache\`. macOS: + `~/Library/Caches/com.uffs`; Linux: `~/.cache/uffs`. +- **Config / settings** (unless `--keep-config`). +- **PATH entries** that point at a removed UFFS root (Windows: the registry, + with open shells notified; macOS/Linux: a manual hint, since the shell owns + PATH). + +The running `uffs.exe` (and `uffs-update.exe`) cannot delete themselves in place +on Windows, so they are scheduled to delete the moment this process exits. + +--- + +## Safety + +- **Dry-run + explicit consent.** Nothing is removed without `--dry-run`-able + review and a `y` at the prompt (or `--yes`). +- **Idempotent.** If a run is interrupted, just run `uffs --uninstall` again — + it finds and removes whatever is left. The next launch tells you a prior run + was interrupted. +- **Best-effort.** A single item that cannot be removed (a locked file, a + permission error) is reported; the rest still proceed, and the final + verification lists anything that survived (and whether a reboot or elevation + is needed). +- **Conservative PATH + strays.** Only PATH entries pointing exactly at a + removed UFFS root are touched; stray `uffs*` files found elsewhere are listed + for you to review, never auto-deleted. + +To remove UFFS entirely: + +```bash +uffs --uninstall --dry-run # review first +uffs --uninstall # then confirm +```