From 08be5afb64b57cc4d4cc5300573b29df2aa15583 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:19:48 -0700 Subject: [PATCH 01/13] feat(cli): scaffold `uffs --uninstall` command (M0) Wire the --uninstall management command and its argument surface, per docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md (M0: tasks U-01, U-03). - dispatch.rs: add Command::Uninstall, the token map, the command-suggest list, the dispatch arm, and a from_token test. - commands/uninstall/args.rs: UninstallArgs + UninstallScope parser (--dry-run / --yes / --keep-config / --no-deep-sweep / --no-path / --scope / --json / --help). Pure, no IO, 9 unit tests. - commands/uninstall/mod.rs: run_uninstall entry + --help text; a temporary M0 scaffold notice until the analysis/removal phases land. Build + strict clippy clean; all uffs-cli tests pass. Analysis, plan, consent, and removal phases (M1+) follow as further commits on this branch. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands.rs | 3 + .../uffs-cli/src/commands/uninstall/args.rs | 183 ++++++++++++++++++ crates/uffs-cli/src/commands/uninstall/mod.rs | 66 +++++++ crates/uffs-cli/src/dispatch.rs | 6 + 4 files changed, 258 insertions(+) create mode 100644 crates/uffs-cli/src/commands/uninstall/args.rs create mode 100644 crates/uffs-cli/src/commands/uninstall/mod.rs 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/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/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs new file mode 100644 index 000000000..18e214417 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -0,0 +1,66 @@ +// 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 (M0 scaffolding). The analysis, plan, +//! consent, and removal phases land in sibling modules as the milestones +//! progress. + +mod args; + +use anyhow::Result; +use args::UninstallArgs; + +/// 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(()); + } + // M1+ will branch here on `parsed` (dry-run analysis, plan, consent, + // removal). Until then, surface a clear scaffolding notice. + print_scaffold_notice(); + Ok(()) +} + +/// 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" + ); +} + +/// Temporary M0 notice printed until the analysis / removal phases land. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_scaffold_notice() { + println!( + "uffs --uninstall is not yet wired to the removal engine (M0 scaffolding).\n\ + Run `uffs --uninstall --help` for the planned flags." + ); +} 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)); From 57da08735bbc63545a2568f9fbdae876fca076f0 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:32:36 -0700 Subject: [PATCH 02/13] =?UTF-8?q?feat(cli):=20`uffs=20--uninstall`=20analy?= =?UTF-8?q?sis=20=E2=80=94=20binary=20resolution=20table=20(M1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only analysis: reuse the self-update Phase-A detection and show every discovered UFFS binary in OS search order, with the copy a bare command runs flagged ACTIVE (the rest shadowed / off-path). Tasks U-02, U-10, U-12 (partial). - update/mod.rs: widen detect() / model / binaries / procinfo to pub(crate) so uninstall reuses the one scanner (behavior-preserving; update tests stay green). - uninstall/resolve_order.rs: pure resolution ordering (Candidate -> ResolvedBinary, case-insensitive PATH rank, ACTIVE / shadowed / off-path), group_and_resolve by stem; 6 unit tests. - uninstall/analyze.rs: flatten the DetectionReport into candidates + build the OS executable search-dir list (process dir, system dirs, cwd, PATH). - uninstall/render.rs: print the resolution table. - uninstall/mod.rs: wire `uffs --uninstall` to run the analysis. Artifact inventory (U-11) and --json (U-12) follow on this branch. Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/analyze.rs | 62 +++++ crates/uffs-cli/src/commands/uninstall/mod.rs | 29 ++- .../uffs-cli/src/commands/uninstall/render.rs | 35 +++ .../src/commands/uninstall/resolve_order.rs | 240 ++++++++++++++++++ crates/uffs-cli/src/commands/update/mod.rs | 8 +- 5 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 crates/uffs-cli/src/commands/uninstall/analyze.rs create mode 100644 crates/uffs-cli/src/commands/uninstall/render.rs create mode 100644 crates/uffs-cli/src/commands/uninstall/resolve_order.rs 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..3cfe3a5d3 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/analyze.rs @@ -0,0 +1,62 @@ +// 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 +} diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 18e214417..5ab1c771a 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -7,11 +7,14 @@ //! - `docs/dev/architecture/UFFS-Uninstall-Feasibility-and-Design.md` //! - `docs/dev/architecture/UFFS-Uninstall-Implementation-Plan.md` //! -//! This is the command entry point (M0 scaffolding). The analysis, plan, -//! consent, and removal phases land in sibling modules as the milestones -//! progress. +//! 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 render; +mod resolve_order; use anyhow::Result; use args::UninstallArgs; @@ -29,9 +32,15 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { print_help(); return Ok(()); } - // M1+ will branch here on `parsed` (dry-run analysis, plan, consent, - // removal). Until then, surface a clear scaffolding notice. - print_scaffold_notice(); + + // M1: read-only analysis. Reuse the self-update Phase-A detection, then + // render the resolution table (which copy a bare `uffs` actually runs). + let report = crate::commands::update::detect(); + let candidates = analyze::build_candidates(&report); + let resolved = resolve_order::group_and_resolve(&candidates, &analyze::search_dirs()); + render::print_resolution_table(&resolved); + + print_pending_removal_notice(); Ok(()) } @@ -56,11 +65,11 @@ fn print_help() { ); } -/// Temporary M0 notice printed until the analysis / removal phases land. +/// Notice printed after the analysis until the removal phases (M2+) land. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn print_scaffold_notice() { +fn print_pending_removal_notice() { println!( - "uffs --uninstall is not yet wired to the removal engine (M0 scaffolding).\n\ - Run `uffs --uninstall --help` for the planned flags." + "\nAnalysis is read-only. The artifact inventory, removal plan, consent,\n\ + and the removal engine itself are not implemented yet (M2+)." ); } 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..774fbad49 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Human rendering of the `uffs --uninstall` analysis (task U-12, partial: the +//! resolution table; the artifact inventory + plan land in later milestones). + +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(), + ); + } + } +} 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/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(); From 93cfe284d566235f0cf95ea7ed68d587367e90f8 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:47:36 -0700 Subject: [PATCH 03/13] feat(cli): `uffs --uninstall` artifact inventory + --json (M1 complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the read-only analysis (tasks U-11, U-12). - uninstall/inventory.rs: resolve every non-binary trace — the data (%LOCALAPPDATA%\uffs), cache (secure_cache_dir), legacy cache (%TEMP%\uffs_index_cache), and per-user config dirs — each with existence + recursive size, plus broker-service state via uffs_winsvc::is_installed. Dedupes by path (config == data on macOS) so removal never double-counts. - uninstall/render.rs: print the inventory (integer-math human byte sizes, no float casts) + the broker service line; full --json (pure analysis_json, unit-tested) of binaries + artifacts + broker state. - uninstall/mod.rs: collect the inventory; --json emits JSON and returns early. `uffs --uninstall` and `--json` now show the complete removal surface, read-only. Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/inventory.rs | 160 ++++++++++++++++++ crates/uffs-cli/src/commands/uninstall/mod.rs | 14 +- .../uffs-cli/src/commands/uninstall/render.rs | 146 +++++++++++++++- 3 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 crates/uffs-cli/src/commands/uninstall/inventory.rs 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/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 5ab1c771a..15413e61f 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -13,6 +13,7 @@ mod analyze; mod args; +mod inventory; mod render; mod resolve_order; @@ -33,13 +34,20 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } - // M1: read-only analysis. Reuse the self-update Phase-A detection, then - // render the resolution table (which copy a bare `uffs` actually runs). + // M1: read-only 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()); - render::print_resolution_table(&resolved); + let inventory = inventory::collect(); + if parsed.json { + render::print_json(&resolved, &inventory); + return Ok(()); + } + + render::print_resolution_table(&resolved); + render::print_inventory(&inventory); print_pending_removal_notice(); Ok(()) } diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 774fbad49..0c879c9f9 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -1,9 +1,13 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! Human rendering of the `uffs --uninstall` analysis (task U-12, partial: the -//! resolution table; the artifact inventory + plan land in later milestones). +//! 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::resolve_order::{ResolutionState, StemResolution}; /// Print the discovered-binary resolution table: for each stem, every copy in @@ -33,3 +37,141 @@ pub(crate) fn print_resolution_table(stems: &[StemResolution]) { } } } + +/// 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(), + ); +} + +/// Emit the full analysis (binaries + artifacts + broker state) as JSON. +#[expect(clippy::print_stdout, reason = "machine-readable CLI output")] +pub(crate) fn print_json(resolution: &[StemResolution], inventory: &Inventory) { + let value = analysis_json(resolution, inventory); + let text = serde_json::to_string_pretty(&value) + .unwrap_or_else(|_| "{\"error\":\"serialize\"}".to_owned()); + println!("{text}"); +} + +/// Build the analysis JSON value (pure; unit-testable without IO). +fn analysis_json(resolution: &[StemResolution], inventory: &Inventory) -> 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(), + }) +} + +/// 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::{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); + assert!(value.get("binaries").is_some()); + assert!(value.get("artifacts").is_some()); + assert_eq!( + value.get("broker_service").and_then(Value::as_str), + Some("absent") + ); + } +} From d05a98a6d6e8cfea300f003fefcc9de48d84f34b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:52:48 -0700 Subject: [PATCH 04/13] feat(cli): `uffs --uninstall` removal plan + dry-run + elevation gate (M2, M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the ordered removal plan from the analysis and gate it on elevation — still entirely read-only (no removal engine yet). Tasks U-20, U-21, U-30. - uninstall/plan.rs: pure build_plan(report, inventory, args) -> RemovalPlan. Groups in safe order (Services -> Processes -> Binaries -> Data/cache/config); WinGet roots become a `winget uninstall` delegation, never a hand-delete; per-item needs_elevation + coarse scope; honors --keep-config / --scope. 6 unit tests (winget-delegated, machine-needs-elevation, service-first, keep-config, scope-user-excludes-service, process-stop). - uninstall/render.rs: print_plan (numbered consent surface + reclaimed bytes), print_elevation_refusal, and the plan in --json (pure plan_json). - uninstall/mod.rs: build the plan; --dry-run prints it and stops; the M3 elevation gate refuses before any effect when the plan needs Administrator the current process lacks (uffs_winsvc::is_elevated). `uffs --uninstall --dry-run` now prints the full ordered plan; --json carries it too. Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 39 +- .../uffs-cli/src/commands/uninstall/plan.rs | 396 ++++++++++++++++++ .../uffs-cli/src/commands/uninstall/render.rs | 92 +++- 3 files changed, 515 insertions(+), 12 deletions(-) create mode 100644 crates/uffs-cli/src/commands/uninstall/plan.rs diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 15413e61f..71914b774 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -14,10 +14,11 @@ mod analyze; mod args; mod inventory; +mod plan; mod render; mod resolve_order; -use anyhow::Result; +use anyhow::{Result, bail}; use args::UninstallArgs; /// Entry point for `uffs --uninstall`. `args` is every token after the @@ -34,24 +35,47 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } - // M1: read-only analysis. Reuse the self-update Phase-A detection for the - // binary resolution table, then inventory the non-binary artifacts. + // 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); if parsed.json { - render::print_json(&resolved, &inventory); + render::print_json(&resolved, &inventory, &removal_plan); return Ok(()); } render::print_resolution_table(&resolved); render::print_inventory(&inventory); + render::print_plan(&removal_plan); + + if parsed.dry_run { + print_dry_run_footer(); + return Ok(()); + } + + // M3 elevation gate (U-30): refuse before any effect when the plan needs + // Administrator the current process does not have. + if removal_plan.requires_elevation() && !uffs_winsvc::is_elevated() { + render::print_elevation_refusal(&removal_plan); + bail!("uninstall needs Administrator for the items listed above; re-run elevated"); + } + + // M4+ : interactive consent + the removal engine land here. print_pending_removal_notice(); Ok(()) } +/// 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."); +} + /// Print `uffs --uninstall` usage. #[expect(clippy::print_stdout, reason = "intentional help output")] fn print_help() { @@ -73,11 +97,12 @@ fn print_help() { ); } -/// Notice printed after the analysis until the removal phases (M2+) land. +/// Notice printed on the would-remove path until the removal engine (M4+) +/// lands. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] fn print_pending_removal_notice() { println!( - "\nAnalysis is read-only. The artifact inventory, removal plan, consent,\n\ - and the removal engine itself are not implemented yet (M2+)." + "\nThe removal engine is not implemented yet (M4+); no changes were made.\n\ + Use `uffs --uninstall --dry-run` to review the plan." ); } 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..a0d5ca8d9 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -0,0 +1,396 @@ +// 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). + +use super::args::{UninstallArgs, UninstallScope}; +use super::inventory::{ArtifactKind, BrokerServiceState, Inventory}; +use crate::commands::update::model::{Channel, DetectionReport, InstallRoot, Scope}; + +/// What a plan item does to its target. Ordering of the variants mirrors the +/// safe removal order (stop before delete; self-delete is handled later). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Action { + /// Stop a running process (daemon / MCP gateway). + StopProcess, + /// Stop + delete the broker Windows service. + RemoveService, + /// Delete UFFS binaries in an unmanaged / dev-build root. + DeleteBinaries, + /// Hand the root to `winget uninstall` (never hand-deleted). + DelegateWinget, + /// Recursively delete a data / cache / config directory. + DeleteDir, +} + +impl Action { + /// Short verb label (used in `--json`). + pub(crate) const fn 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", + } + } +} + +/// 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 this item does. + pub(crate) action: Action, + /// Human description of the target. + pub(crate) description: String, + /// 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. + 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. +pub(crate) fn build_plan( + report: &DetectionReport, + inventory: &Inventory, + args: &UninstallArgs, +) -> RemovalPlan { + let mut groups: Vec = Vec::new(); + + // 1. Services (the broker, elevated) — removed first conceptually. + if inventory.broker_service == BrokerServiceState::Installed { + let item = PlanItem { + action: Action::RemoveService, + description: format!( + "Stop + delete service {}", + uffs_broker_protocol::SERVICE_NAME + ), + 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 { + action: Action::StopProcess, + description: format!("{} (pid {})", process.component.label(), 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 { + action: Action::DeleteDir, + description: format!("{} ({})", dir.kind.label(), dir.path.display()), + needs_elevation: false, + scope: ItemScope::User, + bytes: dir.size_bytes, + }) + .collect(); + push_group(&mut groups, "Data / cache / config", dirs, args.scope); + + RemovalPlan { groups } +} + +/// 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 machine = matches!(root.scope, Scope::Machine); + let scope = if machine { + ItemScope::Machine + } else { + ItemScope::User + }; + let item = match root.channel { + Channel::WinGet => PlanItem { + action: Action::DelegateWinget, + description: format!("winget uninstall SkyLLC.UFFS ({})", root.dir.display()), + needs_elevation: machine, + scope, + bytes: 0, + }, + Channel::Unmanaged | Channel::DevBuild | Channel::Unknown => PlanItem { + action: Action::DeleteBinaries, + description: format!("{} binaries in {}", root.binaries.len(), root.dir.display()), + needs_elevation: machine, + scope, + bytes: 0, + }, + }; + Some(item) +} + +/// 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::{Action, 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 find_action(plan: &RemovalPlan, action: Action) -> bool { + plan.groups + .iter() + .flat_map(|group| &group.items) + .any(|item| item.action == action) + } + + #[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 = build_plan( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(find_action(&plan, Action::DelegateWinget)); + assert!(!find_action(&plan, Action::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 = build_plan( + &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 = build_plan( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &UninstallArgs::default(), + ); + assert!(plan.requires_elevation()); + assert!(find_action(&plan, Action::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 = build_plan(&report, &inv, &UninstallArgs::default()); + let keep = UninstallArgs { + keep_config: true, + ..UninstallArgs::default() + }; + let without_config = build_plan(&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 = build_plan( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &user_only, + ); + assert!(!find_action(&plan, Action::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 = build_plan( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(find_action(&plan, Action::StopProcess)); + } +} diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 0c879c9f9..819b1fccc 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use super::inventory::Inventory; +use super::plan::RemovalPlan; use super::resolve_order::{ResolutionState, StemResolution}; /// Print the discovered-binary resolution table: for each stem, every copy in @@ -62,17 +63,95 @@ pub(crate) fn print_inventory(inventory: &Inventory) { ); } -/// Emit the full analysis (binaries + artifacts + broker state) as JSON. +/// 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.description); + 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.description); + } + } + } + eprintln!("\nRe-run from an elevated shell: uffs --uninstall"); +} + +/// 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) { - let value = analysis_json(resolution, inventory); +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.action.label(), + "description": item.description, + "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) -> Value { +fn analysis_json( + resolution: &[StemResolution], + inventory: &Inventory, + plan: &RemovalPlan, +) -> Value { let binaries: Vec = resolution .iter() .map(|stem| { @@ -112,6 +191,7 @@ fn analysis_json(resolution: &[StemResolution], inventory: &Inventory) -> Value "binaries": binaries, "artifacts": artifacts, "broker_service": inventory.broker_service.label(), + "plan": plan_json(plan), }) } @@ -140,6 +220,7 @@ 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] @@ -166,9 +247,10 @@ mod tests { }], broker_service: BrokerServiceState::Absent, }; - let value = analysis_json(&[], &inventory); + 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") From 87190b557d12317efb4de8a9308f83858557d89b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:08:10 -0700 Subject: [PATCH 05/13] refactor(cli): uninstall plan carries structured targets (M4 prep) Replace the uninstall plan's `Action` enum + string `description` with a structured `PlanTarget` enum that holds the execution data (process pid, service name, root dir + binary stems, winget package id + scope, dir to delete). This is the single source of truth that both the renderer (description / --json) and the upcoming removal executor (M4b) consume, so what is shown is exactly what gets removed. - plan.rs: PlanTarget { StopProcess | RemoveService | DeleteBinaries | DelegateWinget | DeleteDir } with action_label() + describe(); PlanItem now holds `target` instead of action + description; RemovalPlan::items() exposed. Tests match on target variants. - render.rs: derive the plan description + json action from item.target. Output is equivalent (the winget item now also shows its scope). Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../uffs-cli/src/commands/uninstall/plan.rs | 186 ++++++++++++------ .../uffs-cli/src/commands/uninstall/render.rs | 11 +- 2 files changed, 134 insertions(+), 63 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index a0d5ca8d9..a17c4a04f 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -8,36 +8,92 @@ //! 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::PathBuf; use super::args::{UninstallArgs, UninstallScope}; use super::inventory::{ArtifactKind, BrokerServiceState, Inventory}; use crate::commands::update::model::{Channel, DetectionReport, InstallRoot, Scope}; -/// What a plan item does to its target. Ordering of the variants mirrors the -/// safe removal order (stop before delete; self-delete is handled later). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Action { - /// Stop a running process (daemon / MCP gateway). - StopProcess, +/// 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, - /// Delete UFFS binaries in an unmanaged / dev-build root. - DeleteBinaries, - /// Hand the root to `winget uninstall` (never hand-deleted). - DelegateWinget, + 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, + DeleteDir { + /// The directory to remove. + path: PathBuf, + /// The artifact-kind label (e.g. `cache`), for the description. + label: &'static str, + }, } -impl Action { +impl PlanTarget { /// Short verb label (used in `--json`). - pub(crate) const fn label(self) -> &'static str { + 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", + } + } + + /// Human, one-line description of the target. + pub(crate) fn describe(&self) -> String { match self { - Self::StopProcess => "stop-process", - Self::RemoveService => "remove-service", - Self::DeleteBinaries => "delete-binaries", - Self::DelegateWinget => "delegate-winget", - Self::DeleteDir => "delete-dir", + 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()), } } } @@ -56,10 +112,8 @@ pub(crate) enum ItemScope { /// One unit of removal work. #[derive(Debug, Clone)] pub(crate) struct PlanItem { - /// What this item does. - pub(crate) action: Action, - /// Human description of the target. - pub(crate) description: String, + /// 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. @@ -85,8 +139,8 @@ pub(crate) struct RemovalPlan { } impl RemovalPlan { - /// Iterate every item across all groups. - fn items(&self) -> impl Iterator { + /// Iterate every item across all groups, in order. + pub(crate) fn items(&self) -> impl Iterator { self.groups.iter().flat_map(|group| &group.items) } @@ -124,11 +178,9 @@ pub(crate) fn build_plan( // 1. Services (the broker, elevated) — removed first conceptually. if inventory.broker_service == BrokerServiceState::Installed { let item = PlanItem { - action: Action::RemoveService, - description: format!( - "Stop + delete service {}", - uffs_broker_protocol::SERVICE_NAME - ), + target: PlanTarget::RemoveService { + service: uffs_broker_protocol::SERVICE_NAME.to_owned(), + }, needs_elevation: true, scope: ItemScope::Machine, bytes: 0, @@ -141,8 +193,10 @@ pub(crate) fn build_plan( .running .iter() .map(|process| PlanItem { - action: Action::StopProcess, - description: format!("{} (pid {})", process.component.label(), process.pid), + target: PlanTarget::StopProcess { + component: process.component.label().to_owned(), + pid: process.pid, + }, needs_elevation: false, scope: ItemScope::Any, bytes: 0, @@ -166,8 +220,10 @@ pub(crate) fn build_plan( .filter(|dir| dir.exists) .filter(|dir| !(args.keep_config && dir.kind == ArtifactKind::Config)) .map(|dir| PlanItem { - action: Action::DeleteDir, - description: format!("{} ({})", dir.kind.label(), dir.path.display()), + target: PlanTarget::DeleteDir { + path: dir.path.clone(), + label: dir.kind.label(), + }, needs_elevation: false, scope: ItemScope::User, bytes: dir.size_bytes, @@ -189,23 +245,23 @@ fn binary_item(root: &InstallRoot) -> Option { } else { ItemScope::User }; - let item = match root.channel { - Channel::WinGet => PlanItem { - action: Action::DelegateWinget, - description: format!("winget uninstall SkyLLC.UFFS ({})", root.dir.display()), - needs_elevation: machine, - scope, - bytes: 0, + 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 => PlanItem { - action: Action::DeleteBinaries, - description: format!("{} binaries in {}", root.binaries.len(), root.dir.display()), - needs_elevation: machine, - scope, - bytes: 0, + Channel::Unmanaged | Channel::DevBuild | Channel::Unknown => PlanTarget::DeleteBinaries { + dir: root.dir.clone(), + stems: root.binaries.iter().map(|bin| bin.name.clone()).collect(), }, }; - Some(item) + Some(PlanItem { + target, + needs_elevation: machine, + scope, + bytes: 0, + }) } /// Apply the `--scope` filter and append the group only if it has items left. @@ -241,7 +297,7 @@ const fn scope_admits(requested: UninstallScope, item: ItemScope) -> bool { mod tests { use std::path::PathBuf; - use super::{Action, RemovalPlan, build_plan}; + use super::{PlanTarget, RemovalPlan, build_plan}; use crate::commands::uninstall::args::{UninstallArgs, UninstallScope}; use crate::commands::uninstall::inventory::{ ArtifactDir, ArtifactKind, BrokerServiceState, Inventory, @@ -283,11 +339,8 @@ mod tests { } } - fn find_action(plan: &RemovalPlan, action: Action) -> bool { - plan.groups - .iter() - .flat_map(|group| &group.items) - .any(|item| item.action == action) + fn has_target(plan: &RemovalPlan, predicate: impl Fn(&PlanTarget) -> bool) -> bool { + plan.items().any(|item| predicate(&item.target)) } #[test] @@ -301,8 +354,14 @@ mod tests { &inventory(BrokerServiceState::Absent, 1024), &UninstallArgs::default(), ); - assert!(find_action(&plan, Action::DelegateWinget)); - assert!(!find_action(&plan, Action::DeleteBinaries)); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::DelegateWinget { .. } + ))); + assert!(!has_target(&plan, |target| matches!( + target, + PlanTarget::DeleteBinaries { .. } + ))); } #[test] @@ -335,7 +394,10 @@ mod tests { &UninstallArgs::default(), ); assert!(plan.requires_elevation()); - assert!(find_action(&plan, Action::RemoveService)); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + ))); assert_eq!(plan.groups.first().expect("a group").title, "Services"); } @@ -370,7 +432,10 @@ mod tests { &inventory(BrokerServiceState::Installed, 1024), &user_only, ); - assert!(!find_action(&plan, Action::RemoveService)); + assert!(!has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + ))); assert!(!plan.requires_elevation()); } @@ -391,6 +456,9 @@ mod tests { &inventory(BrokerServiceState::Absent, 1024), &UninstallArgs::default(), ); - assert!(find_action(&plan, Action::StopProcess)); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::StopProcess { .. } + ))); } } diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 819b1fccc..fbe594db8 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -81,7 +81,10 @@ pub(crate) fn print_plan(plan: &RemovalPlan) { } else { "" }; - println!(" [{index}] {desc}{elevated}", desc = item.description); + println!( + " [{index}] {desc}{elevated}", + desc = item.target.describe() + ); index = index.saturating_add(1); } } @@ -101,7 +104,7 @@ pub(crate) fn print_elevation_refusal(plan: &RemovalPlan) { for group in &plan.groups { for item in &group.items { if item.needs_elevation { - eprintln!(" - {}", item.description); + eprintln!(" - {}", item.target.describe()); } } } @@ -128,8 +131,8 @@ fn plan_json(plan: &RemovalPlan) -> Value { .iter() .map(|item| { json!({ - "action": item.action.label(), - "description": item.description, + "action": item.target.action_label(), + "description": item.target.describe(), "needs_elevation": item.needs_elevation, "bytes": item.bytes, }) From 4940669eb582e6f2f1993ebe860f48eb9c71c1de Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:35:55 -0700 Subject: [PATCH 06/13] feat(cli): posix ownership pre-check for uninstall elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Mac/Linux the uninstall now runs at user level unless a binary root is actually unwritable by the user. A root-owned /usr/local/bin install is flagged before the executor tries; ~/bin, ~/.cargo/bin, and dev builds need no sudo. Answers "do we really need sudo on posix?": no, unless ownership says so. - uffs-mft platform::system: dir_user_writable(dir) — POSIX access(W_OK) probe (unix-only), exported via uffs_mft::platform. Lives beside is_elevated / geteuid, keeping the libc/unsafe in the platform crate (uffs-cli stays unsafe-free). - uninstall/plan.rs: binaries_need_escalation(scope, dir) — Windows: machine scope; Unix: !dir_user_writable. cfg-gated fns (idiomatic, no unused-param noise). binary_item derives needs_elevation from it. +2 cfg-specific tests. - uninstall/mod.rs: the elevation gate now uses cross-platform uffs_mft::platform::is_elevated (Windows token / Unix euid == 0) instead of the Windows-only uffs_winsvc stub (which is false off Windows). - uninstall/render.rs: refusal names sudo (posix) / elevated shell (windows). Verified: dev root under $HOME → access(W_OK) ok → no escalation. Build + strict clippy clean (uffs-mft + uffs-cli); all tests pass. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 6 +- .../uffs-cli/src/commands/uninstall/plan.rs | 69 +++++++++++++++++-- .../uffs-cli/src/commands/uninstall/render.rs | 5 +- crates/uffs-mft/src/platform.rs | 4 ++ crates/uffs-mft/src/platform/system.rs | 25 +++++++ 5 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 71914b774..666be0460 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -59,8 +59,10 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { } // M3 elevation gate (U-30): refuse before any effect when the plan needs - // Administrator the current process does not have. - if removal_plan.requires_elevation() && !uffs_winsvc::is_elevated() { + // 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"); } diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index a17c4a04f..992ae47be 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -13,7 +13,7 @@ //! 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::PathBuf; +use std::path::{Path, PathBuf}; use super::args::{UninstallArgs, UninstallScope}; use super::inventory::{ArtifactKind, BrokerServiceState, Inventory}; @@ -239,8 +239,8 @@ fn binary_item(root: &InstallRoot) -> Option { if root.binaries.is_empty() { return None; } - let machine = matches!(root.scope, Scope::Machine); - let scope = if machine { + let needs_elevation = binaries_need_escalation(root.scope, &root.dir); + let item_scope = if needs_elevation { ItemScope::Machine } else { ItemScope::User @@ -258,12 +258,37 @@ fn binary_item(root: &InstallRoot) -> Option { }; Some(PlanItem { target, - needs_elevation: machine, - scope, + 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)] +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, @@ -461,4 +486,38 @@ mod tests { PlanTarget::StopProcess { .. } ))); } + + #[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/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index fbe594db8..e3452a3d1 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -108,7 +108,10 @@ pub(crate) fn print_elevation_refusal(plan: &RemovalPlan) { } } } - eprintln!("\nRe-run from an elevated shell: uffs --uninstall"); + eprintln!( + "\nRe-run with elevated privileges (sudo on Linux/macOS, an elevated \ + shell on Windows):\n uffs --uninstall" + ); } /// Emit the full analysis (binaries + artifacts + broker state + plan) as JSON. 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] From 7e1d27546f939c3468ee8ed7f7f82b75a887f231 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:03:14 -0700 Subject: [PATCH 07/13] feat(cli): `uffs --uninstall` removal executor (M4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the destructive engine behind the consent + elevation gates. - uninstall/remove.rs: Effects trait + execute() — walks the ordered plan, dispatches each structured target to the injected sink, and records a per-item outcome. Best-effort: a failing item is recorded and the rest still run (one locked file never strands the cleanup). RecordingEffects fake + 2 executor tests (group order; failure recorded while others continue) — zero real deletions in tests. - uninstall/effects.rs: live SystemEffects — std::fs deletes (idempotent via a try_exists "confirmed absent" check), process stop (kill/taskkill), service removal (uffs_winsvc::stop + sc delete), winget uninstall delegation. Shells out rather than via libc, so the CLI stays unsafe-free. - uninstall/render.rs: print_outcome (counts + failures + retry hint). - uninstall/mod.rs: after the elevation gate, prompt for consent (default No; --yes skips), then execute and report. Empty plan / declined → clean no-op. `uffs --uninstall --dry-run` stays read-only; the real path runs only on explicit confirmation. Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/effects.rs | 172 +++++++++++++ crates/uffs-cli/src/commands/uninstall/mod.rs | 58 ++++- .../uffs-cli/src/commands/uninstall/remove.rs | 228 ++++++++++++++++++ .../uffs-cli/src/commands/uninstall/render.rs | 22 ++ 4 files changed, 467 insertions(+), 13 deletions(-) create mode 100644 crates/uffs-cli/src/commands/uninstall/effects.rs create mode 100644 crates/uffs-cli/src/commands/uninstall/remove.rs 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..adfc1ead8 --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -0,0 +1,172 @@ +// 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; +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())) + } +} + +/// 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 (ignore "already stopped"), then delete. + let _ = uffs_winsvc::stop(service); + 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}")) +} diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 666be0460..44bedaf76 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -13,12 +13,14 @@ mod analyze; mod args; +mod effects; mod inventory; mod plan; +mod remove; mod render; mod resolve_order; -use anyhow::{Result, bail}; +use anyhow::{Context as _, Result, bail}; use args::UninstallArgs; /// Entry point for `uffs --uninstall`. `args` is every token after the @@ -67,17 +69,57 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { bail!("uninstall needs Administrator for the items listed above; re-run elevated"); } - // M4+ : interactive consent + the removal engine land here. - print_pending_removal_notice(); + 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(()); + } + + // 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); Ok(()) } +/// 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() { @@ -98,13 +140,3 @@ fn print_help() { \x20 --help, -h Show this help" ); } - -/// Notice printed on the would-remove path until the removal engine (M4+) -/// lands. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn print_pending_removal_notice() { - println!( - "\nThe removal engine is not implemented yet (M4+); no changes were made.\n\ - Use `uffs --uninstall --dry-run` to review the plan." - ); -} 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..3fe99b4cb --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -0,0 +1,228 @@ +// 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<()>; +} + +/// 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), + } +} + +#[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 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 index e3452a3d1..4766c7288 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -9,6 +9,7 @@ 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 @@ -114,6 +115,27 @@ pub(crate) fn print_elevation_refusal(plan: &RemovalPlan) { ); } +/// 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) { From 9f5aab85e616cba2bd6d89e0395a97b11a056111 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:14:31 -0700 Subject: [PATCH 08/13] feat(cli): `uffs --uninstall` conservative PATH cleanup (M6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer to drop PATH entries that point at a removed UFFS root — only an exact (case-insensitive) match to an unmanaged/dev root we deleted, so it is provably UFFS and safe. WinGet roots and mixed entries are never touched; --no-path skips the group entirely. - plan.rs: PlanTarget::RemovePathEntry; build_plan takes the live PATH and adds a PATH group for matching removed roots (machine-scope PATH flagged elevated). +1 test (offered on match, suppressed by --no-path, untouched on no match). - analyze.rs: path_entries() — the split PATH. - remove.rs: Effects::remove_path_entry + dispatch + recording fake. - effects.rs: Windows edits the persisted user + machine PATH via PowerShell (each scope guarded so a write — and thus elevation — happens only when that scope has the entry; SetEnvironmentVariable broadcasts WM_SETTINGCHANGE). Unix writes a manual-cleanup hint (the shell owns PATH). Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/analyze.rs | 6 ++ .../src/commands/uninstall/effects.rs | 39 +++++++ crates/uffs-cli/src/commands/uninstall/mod.rs | 2 +- .../uffs-cli/src/commands/uninstall/plan.rs | 102 ++++++++++++++++-- .../uffs-cli/src/commands/uninstall/remove.rs | 11 +- 5 files changed, 150 insertions(+), 10 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/analyze.rs b/crates/uffs-cli/src/commands/uninstall/analyze.rs index 3cfe3a5d3..935ca49bf 100644 --- a/crates/uffs-cli/src/commands/uninstall/analyze.rs +++ b/crates/uffs-cli/src/commands/uninstall/analyze.rs @@ -60,3 +60,9 @@ pub(crate) fn search_dirs() -> Vec { } 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/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index adfc1ead8..fd4831afe 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -52,6 +52,45 @@ impl Effects for SystemEffects { 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") } /// The on-disk file name for a binary stem (`uffsd` -> `uffsd.exe` on Windows). diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 44bedaf76..ecac2a7b3 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -44,7 +44,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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); + let removal_plan = plan::build_plan(&report, &inventory, &parsed, &analyze::path_entries()); if parsed.json { render::print_json(&resolved, &inventory, &removal_plan); diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index 992ae47be..cd1c21e6a 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -62,6 +62,11 @@ pub(crate) enum PlanTarget { /// 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 { @@ -73,6 +78,7 @@ impl PlanTarget { Self::DeleteBinaries { .. } => "delete-binaries", Self::DelegateWinget { .. } => "delegate-winget", Self::DeleteDir { .. } => "delete-dir", + Self::RemovePathEntry { .. } => "remove-path-entry", } } @@ -94,6 +100,7 @@ impl PlanTarget { dir.display() ), Self::DeleteDir { path, label } => format!("{label} ({})", path.display()), + Self::RemovePathEntry { dir } => format!("PATH entry {}", dir.display()), } } } @@ -167,11 +174,13 @@ impl RemovalPlan { } } -/// Build the ordered removal plan from the analysis + flags. +/// 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(); @@ -231,9 +240,48 @@ pub(crate) fn build_plan( .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() { @@ -368,13 +416,18 @@ mod tests { 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 = build_plan( + let plan = built( &report, &inventory(BrokerServiceState::Absent, 1024), &UninstallArgs::default(), @@ -399,7 +452,7 @@ mod tests { )], running: Vec::new(), }; - let plan = build_plan( + let plan = built( &report, &inventory(BrokerServiceState::Absent, 1024), &UninstallArgs::default(), @@ -413,7 +466,7 @@ mod tests { roots: Vec::new(), running: Vec::new(), }; - let plan = build_plan( + let plan = built( &report, &inventory(BrokerServiceState::Installed, 1024), &UninstallArgs::default(), @@ -433,12 +486,12 @@ mod tests { running: Vec::new(), }; let inv = inventory(BrokerServiceState::Absent, 4096); - let with_config = build_plan(&report, &inv, &UninstallArgs::default()); + let with_config = built(&report, &inv, &UninstallArgs::default()); let keep = UninstallArgs { keep_config: true, ..UninstallArgs::default() }; - let without_config = build_plan(&report, &inv, &keep); + let without_config = built(&report, &inv, &keep); assert!(with_config.total_bytes() > without_config.total_bytes()); } @@ -452,7 +505,7 @@ mod tests { scope: UninstallScope::User, ..UninstallArgs::default() }; - let plan = build_plan( + let plan = built( &report, &inventory(BrokerServiceState::Installed, 1024), &user_only, @@ -476,7 +529,7 @@ mod tests { version: None, }], }; - let plan = build_plan( + let plan = built( &report, &inventory(BrokerServiceState::Absent, 1024), &UninstallArgs::default(), @@ -487,6 +540,39 @@ mod tests { ))); } + #[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() { diff --git a/crates/uffs-cli/src/commands/uninstall/remove.rs b/crates/uffs-cli/src/commands/uninstall/remove.rs index 3fe99b4cb..e66adefd3 100644 --- a/crates/uffs-cli/src/commands/uninstall/remove.rs +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -31,6 +31,9 @@ pub(crate) trait Effects { 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. @@ -102,6 +105,7 @@ fn dispatch(target: &PlanTarget, effects: &mut dyn Effects) -> Result<()> { package_id, scope, .. } => effects.delegate_winget(package_id, *scope), PlanTarget::DeleteDir { path, .. } => effects.remove_dir(path), + PlanTarget::RemovePathEntry { dir } => effects.remove_path_entry(dir), } } @@ -156,6 +160,11 @@ mod tests { } 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 { @@ -187,7 +196,7 @@ mod tests { }], broker_service: BrokerServiceState::Absent, }; - build_plan(&report, &inventory, &UninstallArgs::default()) + build_plan(&report, &inventory, &UninstallArgs::default(), &[]) } #[test] From 1bb96ead8925382b3972011ea9268cf37070a706 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:26:29 -0700 Subject: [PATCH 09/13] feat(cli): `uffs --uninstall` deep sweep for stray files (M7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eat our own dog food: while the daemon is up, ask UFFS itself to find stray family files (uffs*.exe, *_compact.uffs, *_usn.cursor, ...) anywhere on the indexed drives, beyond the known install roots. Strays are REPORTED for review, never auto-removed — a uffs.exe under Downloads may be the user's own copy. - sweep.rs: Search trait + find_strays (pure: per-pattern search, drop hits already under a planned dir, sort + dedup; separator-aware prefix so /opt/uffs never matches /opt/uffs-other). DaemonSearch live backend via uffs_client search_cli_raw, best-effort (no daemon → no hits, never fails) with defensive recursive JSON path extraction. 3 tests (dedup/filter, sibling-prefix, json extraction). - render.rs: print_strays (review-only listing). - mod.rs: run the sweep after the plan (skipped by --no-deep-sweep); plan_dirs helper feeds the known-dir filter. Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 28 +++ .../uffs-cli/src/commands/uninstall/render.rs | 16 ++ .../uffs-cli/src/commands/uninstall/sweep.rs | 166 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 crates/uffs-cli/src/commands/uninstall/sweep.rs diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index ecac2a7b3..ee220eb56 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -19,9 +19,13 @@ mod plan; mod remove; mod render; mod resolve_order; +mod sweep; + +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. @@ -55,6 +59,16 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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(()); @@ -88,6 +102,20 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { Ok(()) } +/// 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")] diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 4766c7288..0f7a4b484 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -115,6 +115,22 @@ pub(crate) fn print_elevation_refusal(plan: &RemovalPlan) { ); } +/// 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()); + } +} + /// 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) { 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); + } +} From 342654523bfa42fed56515664140b662ea93de72 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:31:55 -0700 Subject: [PATCH 10/13] feat(cli): `uffs --uninstall` self-delete + post-removal verify (M8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - effects.rs: schedule_self_delete — the running uffs.exe (+ uffs-update.exe) cannot delete their own image on Windows, so spawn a detached cmd that waits for this process to exit then deletes them (the classic self-delete, no FFI). Unix unlinks them directly. - verify.rs: still_present — re-stat the targeted locations after removal and report any that survived (daemon-free; the search service is gone by then). 2 tests. - render.rs: print_verification (clean / leftovers) + print_self_delete_warning. - mod.rs: after execute, schedule the self-delete (warn honestly if even that fails), then verify the remaining locations (excluding the reboot-deferred self-binaries). Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/effects.rs | 40 +++++++++++++++++- crates/uffs-cli/src/commands/uninstall/mod.rs | 39 +++++++++++++++++ .../uffs-cli/src/commands/uninstall/render.rs | 26 ++++++++++++ .../uffs-cli/src/commands/uninstall/verify.rs | 42 +++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/uffs-cli/src/commands/uninstall/verify.rs diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index fd4831afe..130d7aaa9 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -9,7 +9,7 @@ //! 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; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context as _, Result, bail}; @@ -93,6 +93,44 @@ fn remove_path_entry_impl(dir: &Path) -> Result<()> { .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: String = 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 & {deletes}rem self-delete"); + 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)] diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index ee220eb56..783294e97 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -20,6 +20,7 @@ mod remove; mod render; mod resolve_order; mod sweep; +mod verify; use std::path::PathBuf; @@ -99,9 +100,47 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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)); 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 { diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 0f7a4b484..276ca1bd3 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -131,6 +131,32 @@ pub(crate) fn print_strays(strays: &[std::path::PathBuf]) { } } +/// 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) { 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()); + } +} From a9af51ba4418726e31d5b0943c81c10e109291d7 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:36:13 -0700 Subject: [PATCH 11/13] feat(cli): `uffs --uninstall` crash-awareness marker (M9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-sized resume. The uninstall is idempotent (deletes are try_exists-guarded, service/winget removals no-op when gone, the self-delete is reboot-deferred), so "resume" is just re-running it — re-detection finds and removes whatever is left. That is the key difference from the non-idempotent self-update swaps that genuinely need a full replay journal. So M9 is a small in-progress marker written to the system temp dir (which survives the lifecycle-dir deletion): - journal.rs: begin / finish / was_interrupted over a temp-dir marker; idempotent clear; round-trip test. - mod.rs: on launch, note an interrupted prior run; mark in-progress before removal; clear on a clean finish — each warning surfaced honestly, never blocking the uninstall. - render.rs: print_resumed_note + print_journal_warning. Build + strict clippy clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/journal.rs | 89 +++++++++++++++++++ crates/uffs-cli/src/commands/uninstall/mod.rs | 19 ++++ .../uffs-cli/src/commands/uninstall/render.rs | 15 ++++ 3 files changed, 123 insertions(+) create mode 100644 crates/uffs-cli/src/commands/uninstall/journal.rs 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 index 783294e97..fdbed7580 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -15,6 +15,7 @@ mod analyze; mod args; mod effects; mod inventory; +mod journal; mod plan; mod remove; mod render; @@ -42,6 +43,12 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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(); @@ -95,6 +102,13 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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(); @@ -120,6 +134,11 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { }) .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(()) } diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 276ca1bd3..00ae2f7f9 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -131,6 +131,21 @@ pub(crate) fn print_strays(strays: &[std::path::PathBuf]) { } } +/// 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) { From 56ab607b30deeb9f0c5cd770ede62976607553f6 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:47:40 -0700 Subject: [PATCH 12/13] docs(cli): `uffs --uninstall` user manual + changelog + live effects test (M11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/user-manual/uninstall.md: full user guide (steps, elevation table, WinGet delegation, flags, what-gets-removed, safety). Linked from the manual index next to Updating. - CHANGELOG: ## [Unreleased] entry for `uffs --uninstall`. - effects.rs: U-112 — a real test of the live SystemEffects delete path on throwaway temp files (idempotent delete_binaries + remove_dir); no UFFS install is touched. Completes the uninstall implementation plan (M0-M11). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 24 ++++ .../src/commands/uninstall/effects.rs | 34 +++++ docs/user-manual/index.md | 1 + docs/user-manual/uninstall.md | 126 ++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 docs/user-manual/uninstall.md 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/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index 130d7aaa9..afa77cf49 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -247,3 +247,37 @@ fn winget_uninstall(package_id: &str, scope: Scope) -> Result<()> { } 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/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 +``` From cf8c1120fc389e0e0311044b3c2a1522e58362f1 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:01:24 -0700 Subject: [PATCH 13/13] fix(cli): satisfy Windows-target clippy in uninstall effects/plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cfg(windows) uninstall code is only linted by the cross (xwin) clippy in the pre-push gate, which flagged four nits macOS clippy never sees: - effects.rs schedule_self_delete: build the del-command string via map→Vec→join instead of format!-collect (format_collect). - effects.rs remove_windows_service: discard the best-effort `uffs_winsvc::stop` result with an explicit `match { Ok(()) | Err(_) => {} }` (no non-binding `let _` on a must-use). - plan.rs binaries_need_escalation (Windows variant): make it `const fn`. macOS + `cargo xwin clippy --target x86_64-pc-windows-msvc -D warnings` both clean; all uffs-cli tests pass. Co-Authored-By: Claude Opus 4.8 --- .../uffs-cli/src/commands/uninstall/effects.rs | 16 +++++++++++----- crates/uffs-cli/src/commands/uninstall/plan.rs | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index afa77cf49..0b20ab4fe 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -105,13 +105,16 @@ pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> { if paths.is_empty() { return Ok(()); } - let deletes: String = paths + let deletes: Vec = paths .iter() - .map(|path| format!("del /f /q \"{}\" & ", path.display())) + .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 & {deletes}rem self-delete"); + 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()) @@ -210,8 +213,11 @@ fn stop_command(pid_str: &str) -> Command { /// service exists, so the plan never produces this item). #[cfg(windows)] fn remove_windows_service(service: &str) -> Result<()> { - // Best-effort stop first (ignore "already stopped"), then delete. - let _ = uffs_winsvc::stop(service); + // 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}"), diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index cd1c21e6a..5fae0e4cf 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -318,7 +318,7 @@ fn binary_item(root: &InstallRoot) -> Option { /// Windows: machine-scope roots (`%PROGRAMFILES%`) need Administrator; the /// classified scope already captures this. #[cfg(windows)] -fn binaries_need_escalation(scope: Scope, _dir: &Path) -> bool { +const fn binaries_need_escalation(scope: Scope, _dir: &Path) -> bool { matches!(scope, Scope::Machine) }