Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/uffs-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
68 changes: 68 additions & 0 deletions crates/uffs-cli/src/commands/uninstall/analyze.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2025-2026 SKY, LLC.

//! Impure glue for the `uffs --uninstall` analysis (tasks U-10/U-12): turn the
//! reused Phase-A [`DetectionReport`] into resolution [`Candidate`]s, and build
//! the OS executable search-dir list the pure ordering consumes.
//!
//! Side effects are confined to reading the environment (`current_exe`, `PATH`,
//! `current_dir`, `SystemRoot`); nothing here mutates the system.

use std::path::PathBuf;

use super::resolve_order::Candidate;
use crate::commands::update::model::DetectionReport;

/// Flatten the detection report's roots × binaries into resolution candidates
/// (one per discovered binary copy).
pub(crate) fn build_candidates(report: &DetectionReport) -> Vec<Candidate> {
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<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(exe) = std::env::current_exe()
&& let Some(parent) = exe.parent()
{
dirs.push(parent.to_path_buf());
}
#[cfg(windows)]
{
if let Some(system_root) = std::env::var_os("SystemRoot") {
let root = PathBuf::from(system_root);
dirs.push(root.join("System32"));
dirs.push(root);
}
}
if let Ok(cwd) = std::env::current_dir() {
dirs.push(cwd);
}
if let Some(path) = std::env::var_os("PATH") {
for entry in std::env::split_paths(&path) {
dirs.push(entry);
}
}
dirs
}

/// The directories on the current `PATH`, in order. Used to offer removal of a
/// PATH entry that points at a UFFS root.
pub(crate) fn path_entries() -> Vec<PathBuf> {
std::env::var_os("PATH").map_or_else(Vec::new, |path| std::env::split_paths(&path).collect())
}
183 changes: 183 additions & 0 deletions crates/uffs-cli/src/commands/uninstall/args.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<Self> {
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<UninstallArgs> {
let owned: Vec<String> = 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);
}
}
Loading
Loading