From 5ddcf08d1955f7e27fb25e615a3bb0a0202b2db1 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:08:46 -0700 Subject: [PATCH 01/40] fix(cli): strip Windows \\?\ verbatim prefix in uninstall path resolution On Windows, std::fs::canonicalize returns the \\?\ extended-length form, which never matches a bare C:\... PATH entry. The live v0.6.18 run showed every binary (including the active uffs) mislabeled 'off-path' and the install dir displayed as \\?\C:\Users\rnio\bin, and it means the PATH-safety gate can't recognize a PATH entry either. Add strip_verbatim_prefix and apply it at the canonicalize sites (detect's upsert_root, the uninstall PATH scan) and to the current-exe dir in search_dirs, so stored dirs match plain PATH entries and display cleanly. No-op off Windows. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/analyze.rs | 8 +++- crates/uffs-cli/src/commands/update/mod.rs | 47 +++++++++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/analyze.rs b/crates/uffs-cli/src/commands/uninstall/analyze.rs index c8d725e59..1f5d01cad 100644 --- a/crates/uffs-cli/src/commands/uninstall/analyze.rs +++ b/crates/uffs-cli/src/commands/uninstall/analyze.rs @@ -77,7 +77,9 @@ pub(crate) fn augment_with_path_locations(report: &mut DetectionReport) { fn add_roots_for_dirs(report: &mut DetectionReport, dirs: &[PathBuf]) { let mut seen: Vec = report.roots.iter().map(|root| root.dir.clone()).collect(); for dir in dirs { - let key = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()); + let key = crate::commands::update::strip_verbatim_prefix( + std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone()), + ); if seen.iter().any(|existing| existing == &key) { continue; } @@ -148,7 +150,9 @@ pub(crate) fn search_dirs() -> Vec { if let Ok(exe) = std::env::current_exe() && let Some(parent) = exe.parent() { - dirs.push(parent.to_path_buf()); + dirs.push(crate::commands::update::strip_verbatim_prefix( + parent.to_path_buf(), + )); } #[cfg(windows)] { diff --git a/crates/uffs-cli/src/commands/update/mod.rs b/crates/uffs-cli/src/commands/update/mod.rs index 547f221d6..4f06031c2 100644 --- a/crates/uffs-cli/src/commands/update/mod.rs +++ b/crates/uffs-cli/src/commands/update/mod.rs @@ -385,10 +385,34 @@ pub(crate) fn detect() -> DetectionReport { /// Directory of the currently-running `uffs` executable. fn current_exe_dir() -> Option { - std::env::current_exe() + let parent = std::env::current_exe() .ok()? .parent() - .map(Path::to_path_buf) + .map(Path::to_path_buf)?; + Some(strip_verbatim_prefix(parent)) +} + +/// Strip the Windows `\\?\` verbatim prefix from a (typically canonicalized) +/// path so it matches plain `PATH` entries and displays cleanly +/// (`\\?\C:\x` -> `C:\x`, `\\?\UNC\srv\sh` -> `\\srv\sh`). No-op off Windows +/// and on already-plain paths. `std::fs::canonicalize` on Windows always +/// returns the verbatim form, which otherwise never matches a bare `C:\…` PATH +/// entry — the cause of the resolution table mislabeling the active copy +/// `off-path`. +pub(crate) fn strip_verbatim_prefix(path: PathBuf) -> PathBuf { + // Runs on every platform: a non-Windows path never carries a `\\?\` prefix, + // so [`strip_verbatim_str`] returns `None` and the path is left untouched. + let stripped = path.to_str().and_then(strip_verbatim_str); + stripped.map_or(path, PathBuf::from) +} + +/// Pure verbatim-prefix strip for [`strip_verbatim_prefix`], split out so it is +/// testable on every platform. Returns `None` when `text` has no `\\?\` prefix. +fn strip_verbatim_str(text: &str) -> Option { + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return Some(format!(r"\\{rest}")); + } + text.strip_prefix(r"\\?\").map(ToOwned::to_owned) } /// Resolve the running daemon's pid — PID file first, then a name scan. @@ -400,7 +424,7 @@ fn daemon_pid() -> Option { /// Insert `dir` as an install root (deduplicated by canonical path) and /// record that `anchor` surfaced it. fn upsert_root(roots: &mut Vec, dir: PathBuf, anchor: Anchor) { - let key = std::fs::canonicalize(&dir).unwrap_or(dir); + let key = strip_verbatim_prefix(std::fs::canonicalize(&dir).unwrap_or(dir)); if let Some(existing) = roots.iter_mut().find(|root| root.dir == key) { existing.note_anchor(anchor); return; @@ -549,7 +573,22 @@ fn print_phase_a_footer() { #[cfg(test)] mod tests { use super::model::{Anchor, InstallRoot}; - use super::{normalize_tag, upsert_root}; + use super::{normalize_tag, strip_verbatim_str, upsert_root}; + + #[test] + fn strip_verbatim_str_handles_drive_unc_and_plain() { + assert_eq!( + strip_verbatim_str(r"\\?\C:\Users\rnio\bin").as_deref(), + Some(r"C:\Users\rnio\bin") + ); + assert_eq!( + strip_verbatim_str(r"\\?\UNC\server\share\bin").as_deref(), + Some(r"\\server\share\bin") + ); + // A plain path has no verbatim prefix -> None (left untouched upstream). + assert_eq!(strip_verbatim_str(r"C:\Users\rnio\bin"), None); + assert_eq!(strip_verbatim_str("/usr/local/bin"), None); + } #[test] fn normalize_tag_strips_leading_v_only() { From 7e9d50a03d46b4377121d6520351c402c20e0124 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:12:34 -0700 Subject: [PATCH 02/40] fix(cli): uninstall deep sweep decodes every search payload (was finding zero strays) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live v0.6.18 run surfaced no strays despite dozens of stray uffs.exe copies on disk. Root cause: the daemon delivers search results in four shapes — inline rows, a memory-mapped rows file, an inline pre-formatted blob, or a memory-mapped blob (chosen by size + output shape). A real multi-hit Windows sweep returns a CSV/path *blob*, but the old code walked the JSON for `"path"` object keys, which only exist in the inline-rows case — so every blob/shmem result was silently dropped. Switch to the typed `search_cli` + `--columns path` (single-column output) and decode all payload variants via the client's shmem/blob helpers (read_search_results / stream_paths_blob_into), parsing the path-per-line blob (header + CSV quotes stripped). Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../uffs-cli/src/commands/uninstall/sweep.rs | 93 ++++++++++++------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index 7282bca79..f525eb8f4 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -14,7 +14,6 @@ use std::path::{Path, PathBuf}; use anyhow::Result; -use serde_json::Value; /// Family-file name patterns the sweep searches for. const STRAY_PATTERNS: &[&str] = &[ @@ -108,54 +107,76 @@ impl Search for DaemonSearch { let Ok(mut client) = uffs_client::connect_sync::UffsClientSync::connect_raw() else { return Ok(Vec::new()); }; + // `--columns path` forces single-column output so the daemon's path / + // CSV blob fast paths yield clean one-path-per-line text rather than a + // multi-column CSV blob (which has no JSON `path` field — the original + // bug, where a real multi-hit Windows sweep returned a blob and the + // JSON `"path"`-key walk found nothing). let args = vec![ pattern.to_owned(), "--files-only".to_owned(), + "--columns".to_owned(), + "path".to_owned(), "--limit".to_owned(), - "1000".to_owned(), + "5000".to_owned(), ]; - let Ok(value) = client.search_cli_raw(&args) else { + let Ok(response) = client.search_cli(&args) else { return Ok(Vec::new()); }; - Ok(extract_paths(&value)) + Ok(payload_paths(response.payload)) } } -/// 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); - } +/// Decode every payload variant the daemon may return into result paths. A +/// search response arrives as inline rows, a memory-mapped rows file, an inline +/// pre-formatted blob, or a memory-mapped blob — the daemon picks by size + +/// output shape — so reading only one shape (the old JSON `"path"` walk, which +/// saw just the inline-rows case) silently dropped every blob/shmem result. +fn payload_paths(payload: uffs_client::protocol::response::SearchPayload) -> Vec { + use uffs_client::protocol::response::SearchPayload as Payload; + match payload { + Payload::InlineRows(rows) => rows + .into_iter() + .map(|row| PathBuf::from(row.path)) + .collect(), + Payload::ShmemRows { path, .. } => { + uffs_client::shmem::read_search_results(Path::new(&path)) + .map(|resp| payload_paths(resp.payload)) + .unwrap_or_default() } - Value::Array(items) => { - for item in items { - collect_paths(item, out); + Payload::InlineBlob(blob) => blob_lines_to_paths(&blob), + Payload::ShmemBlob(path) => { + let mut buf: Vec = Vec::new(); + if uffs_client::shmem::stream_paths_blob_into(Path::new(&path), &mut buf).is_ok() { + blob_lines_to_paths(&String::from_utf8_lossy(&buf)) + } else { + Vec::new() } } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + Payload::Empty => Vec::new(), } } +/// Parse a single-column (`--columns path`) text blob into paths: one per +/// non-empty line, dropping a leading `path`/`Path` header line and any +/// surrounding CSV quotes. +fn blob_lines_to_paths(blob: &str) -> Vec { + blob.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| line.trim_matches('"')) + .filter(|line| !line.eq_ignore_ascii_case("path")) + .map(PathBuf::from) + .collect() +} + #[cfg(test)] mod tests { use std::path::PathBuf; use anyhow::Result; - use super::{Search, extract_paths, find_strays, version_strays}; + use super::{Search, blob_lines_to_paths, find_strays, version_strays}; /// Returns the same hits for every pattern (the dedup must collapse them). struct FakeSearch(Vec); @@ -208,11 +229,17 @@ mod tests { } #[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); + fn blob_lines_drop_header_and_quotes() { + // A single-column (`--columns path`) CSV blob: header line, quoted + // Windows paths, a blank trailing line. + let blob = "\"Path\"\r\n\"C:\\Users\\me\\bin\\uffs.exe\"\r\n\"D:\\tools\\uffsd.exe\"\r\n"; + let paths = blob_lines_to_paths(blob); + assert_eq!(paths, vec![ + PathBuf::from(r"C:\Users\me\bin\uffs.exe"), + PathBuf::from(r"D:\tools\uffsd.exe"), + ]); + // A bare path-per-line blob (no header, no quotes) also works. + let plain = "/opt/uffs/uffs\n/home/me/Downloads/uffs\n"; + assert_eq!(blob_lines_to_paths(plain).len(), 2); } } From 84f5cabbc3eea6bac627203c634b6ff7a4dc1d26 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:14:13 -0700 Subject: [PATCH 03/40] fix(cli): uninstall drive-coverage prompt warns that indexing builds a cache The live v0.6.18 dry-runs grew a ~4 GB index cache because each 'y' to the coverage offer indexed more drives. That is by design (indexing is non-destructive and the deep sweep needs it), but the prompt gave no hint that saying yes builds an on-disk cache that persists even under --dry-run. Spell it out so the choice is informed. Windows-only module. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/coverage.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 6874aa4a9..f301b989b 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -65,7 +65,9 @@ pub(crate) fn ensure_drive_coverage(confirm: &mut dyn FnMut(&str) -> Result Date: Tue, 30 Jun 2026 09:20:50 -0700 Subject: [PATCH 04/40] fix(cli): uninstall drive-coverage polls status_drives for readiness, not a blind wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A freshly load_drive_letters-requested shard starts parked/cold and only becomes searchable once its body is resident. The previous fixed await_ready(120s) could return while shards were still loading, so the deep sweep searched a not-yet-ready index and found nothing. Poll status_drives until every requested drive reports a loaded tier (hot/warm) or the deadline elapses — so the sweep waits exactly as long as needed and never searches a still-parked shard. Best-effort: RPC errors keep polling to the deadline, then proceed with whatever is loaded. Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index f301b989b..552e9f2bc 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -70,8 +70,37 @@ pub(crate) fn ensure_drive_coverage(confirm: &mut dyn FnMut(&str) -> Result= deadline { + return; + } + std::thread::sleep(POLL_INTERVAL); + } +} From 81a1b9068fb127ce703128c614926c386fa928b9 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:07:10 -0700 Subject: [PATCH 05/40] feat(cli): stamp the git commit into `uffs --version` to verify the running build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live Windows test ran an OLD uffs.exe: the shell prompt said the fix/uninstall-windows-followups branch was checked out, but the output still showed the pre-fix behaviour (off-path, \\?\ paths) — a stale binary never rebuilt/redeployed. The CLI's --version printed only the crate version, so there was no way to tell which build was running. The daemon already stamps UFFS_GIT_SHA into its startup log to close exactly this 'ran the wrong/stale binary' trap; port the same build.rs stamp to the CLI and surface it: `uffs --version` now prints 'uffs ([-dirty])'. Match the sha against `git rev-parse --short HEAD` to confirm the build. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/build.rs | 37 +++++++++++++++++++++++++++++++++++++ crates/uffs-cli/src/args.rs | 12 ++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/uffs-cli/build.rs b/crates/uffs-cli/build.rs index 8256fa9dc..5837d6186 100644 --- a/crates/uffs-cli/build.rs +++ b/crates/uffs-cli/build.rs @@ -99,6 +99,13 @@ fn main() { println!("cargo:rerun-if-changed=app.manifest"); println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + // Stamp the short git commit (+ `-dirty`) into `UFFS_GIT_SHA` so + // `uffs --version` can tie a running binary back to the exact build — + // closing the "ran a stale binary" trap. The daemon already does this in its + // startup log; the CLI surfaced no commit, so a rebuilt-but-not-deployed + // uffs.exe was indistinguishable from the old one. + emit_git_sha(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); @@ -131,3 +138,33 @@ fn main() { .expect("winresource: failed to embed icon + manifest"); } } + +/// Emit `UFFS_GIT_SHA` = the short `HEAD` commit, with a `-dirty` suffix when +/// the working tree has uncommitted changes (so a hand-tweaked local build is +/// never mistaken for the clean commit). Best-effort: `unknown` when git is +/// absent. Mirrors `uffs-daemon`'s build stamp; `../../.git/HEAD` is watched so +/// the stamp tracks the checked-out commit. +fn emit_git_sha() { + use std::process::Command; + + let sha = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|raw| raw.trim().to_owned()) + .filter(|trimmed| !trimmed.is_empty()) + .unwrap_or_else(|| "unknown".to_owned()); + + let dirty = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .ok() + .filter(|out| out.status.success()) + .is_some_and(|out| !out.stdout.is_empty()); + + let stamp = if dirty { format!("{sha}-dirty") } else { sha }; + println!("cargo:rustc-env=UFFS_GIT_SHA={stamp}"); + println!("cargo:rerun-if-changed=../../.git/HEAD"); +} diff --git a/crates/uffs-cli/src/args.rs b/crates/uffs-cli/src/args.rs index 40c9629cd..6cc0c5a5d 100644 --- a/crates/uffs-cli/src/args.rs +++ b/crates/uffs-cli/src/args.rs @@ -518,10 +518,18 @@ pub(crate) fn print_help() { print!("{HELP}"); } -/// Print version and exit. +/// Print version and exit. Includes the build's short git commit (stamped by +/// `build.rs` into `UFFS_GIT_SHA`, with `-dirty` for an uncommitted tree) so a +/// running binary can be tied to the exact source it was built from — match it +/// against `git rev-parse --short HEAD` to confirm you are not on a stale +/// build. #[expect(clippy::print_stdout, reason = "intentional version output")] pub(crate) fn print_version() { - println!("uffs {}", env!("CARGO_PKG_VERSION")); + println!( + "uffs {} ({})", + env!("CARGO_PKG_VERSION"), + option_env!("UFFS_GIT_SHA").unwrap_or("unknown") + ); } // ── Subcommand help texts ───────────────────────────────────────────── From e393be1250f479485f33171e9ce72cf33caa15a7 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:18:45 -0700 Subject: [PATCH 06/40] fix(cli): uninstall deep sweep keeps only real family files, not substring noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live Windows sweep worked but listed non-binaries as removable strays: the daemon search matches `uffs.exe` as a *contains* query, so it also returned prefetch traces (UFFS.EXE-1234.pf), localized resources (uffs.exe.mui), checksums (uffs.exe.sha256), build recipes (uffs.exe.recipe), and NTFS alternate-data-stream entries (uffs.exe:com.dropbox.attrs). Add is_family_artifact: keep a hit only when its file name is exactly a family executable (uffs.exe / uffsd.exe / uffs-broker.exe / uffs-tui*.exe / …) or a cache file (*_compact.uffs / *_usn.cursor); drop anything ending in .pf/.mui/.sha256/.recipe or containing ':' (an ADS entry). Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../uffs-cli/src/commands/uninstall/sweep.rs | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index f525eb8f4..e307caffd 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -76,7 +76,7 @@ pub(crate) fn find_strays(search: &mut dyn Search, known_dirs: &[PathBuf]) -> Re let mut strays: Vec = Vec::new(); for pattern in STRAY_PATTERNS { for hit in search.find(pattern)? { - if !is_under_any(&hit, known_dirs) { + if is_family_artifact(&hit) && !is_under_any(&hit, known_dirs) { strays.push(hit); } } @@ -86,6 +86,46 @@ pub(crate) fn find_strays(search: &mut dyn Search, known_dirs: &[PathBuf]) -> Re Ok(strays) } +/// Whether `path`'s file name is *exactly* a UFFS family executable or cache +/// file we would actually remove — not a derived artifact that merely contains +/// a family name as a substring. +/// +/// The daemon search matches `uffs.exe` as a *contains* query, so a raw sweep +/// also returns prefetch traces (`UFFS.EXE-1234.pf`), localized resources +/// (`uffs.exe.mui`), checksums (`uffs.exe.sha256`), build recipes +/// (`uffs.exe.recipe`), and NTFS alternate-data-stream entries +/// (`uffs.exe:com.dropbox.attrs`). None of those are ours to delete; this keeps +/// only an exact `*.exe` family binary or a `*_compact.uffs` / `*_usn.cursor` +/// cache file. +fn is_family_artifact(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|raw| raw.to_str()) else { + return false; + }; + // An alternate-data-stream entry (`file:stream`) is never a real file. + if name.contains(':') { + return false; + } + let lower = name.to_ascii_lowercase(); + if lower.ends_with("_compact.uffs") || lower.ends_with("_usn.cursor") { + return true; + } + let Some(stem) = lower.strip_suffix(".exe") else { + return false; + }; + FAMILY_EXE_STEMS.contains(&stem) || stem.starts_with("uffs-tui") || stem.starts_with("uffs-gui") +} + +/// Exact `*.exe` family stems the deep sweep removes; the optional `uffs-tui*` +/// / `uffs-gui*` members are matched by prefix in [`is_family_artifact`]. +const FAMILY_EXE_STEMS: &[&str] = &[ + "uffs", + "uffsd", + "uffsmcp", + "uffs-broker", + "uffs-update", + "uffs-mft", +]; + /// 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 { @@ -172,11 +212,34 @@ fn blob_lines_to_paths(blob: &str) -> Vec { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::path::{Path, PathBuf}; use anyhow::Result; - use super::{Search, blob_lines_to_paths, find_strays, version_strays}; + use super::{Search, blob_lines_to_paths, find_strays, is_family_artifact, version_strays}; + + #[test] + fn family_artifact_filter_keeps_binaries_drops_noise() { + // Real removable family files. + assert!(is_family_artifact(Path::new(r"C:\x\uffs.exe"))); + assert!(is_family_artifact(Path::new(r"C:\x\uffsd.exe"))); + assert!(is_family_artifact(Path::new(r"C:\x\uffs-broker.exe"))); + assert!(is_family_artifact(Path::new(r"C:\x\uffs-tui-x86.exe"))); + assert!(is_family_artifact(Path::new(r"C:\x\drive_c_compact.uffs"))); + assert!(is_family_artifact(Path::new(r"C:\x\journal_usn.cursor"))); + // Noise the daemon's substring search also returns — must be dropped. + assert!(!is_family_artifact(Path::new( + r"C:\Windows\Prefetch\UFFS.EXE-1867467A.pf" + ))); + assert!(!is_family_artifact(Path::new(r"C:\x\uffs.exe.mui"))); + assert!(!is_family_artifact(Path::new(r"C:\x\uffs.exe.sha256"))); + assert!(!is_family_artifact(Path::new(r"C:\x\uffs.exe.recipe"))); + assert!(!is_family_artifact(Path::new( + r"C:\x\uffs.exe:com.dropbox.attrs" + ))); + // A foreign exe that merely contains "uffs.exe" as a substring. + assert!(!is_family_artifact(Path::new(r"C:\x\notuffs.exe"))); + } /// Returns the same hits for every pattern (the dedup must collapse them). struct FakeSearch(Vec); From 6d52931b7d5f95790ed89251a03e77ce488c6dae Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:25:53 -0700 Subject: [PATCH 07/40] fix(client): exe-resolution fallbacks carry .exe on Windows (no bare uffs name) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the `uffs` -> `uffs.com` concern: every actual spawn site already uses a full current-exe-relative path, and Rust's Command appends .exe (never .com) on Windows — which is why the uninstall run completed correctly. The only bare names were the $PATH fallbacks in find_uffs_exe / find_daemon_exe, hit only when current_exe + sibling lookup both fail. Harden those to the platform binary name (uffs.exe / uffsd.exe on Windows) so a bare `uffs` can never be resolved to a legacy uffs.com via PATHEXT (.COM precedes .EXE) if the path is ever handed to a shell, a registry entry, or a logged command rather than spawned directly. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-client/src/daemon_ctl.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/uffs-client/src/daemon_ctl.rs b/crates/uffs-client/src/daemon_ctl.rs index 6f6524921..4098078ce 100644 --- a/crates/uffs-client/src/daemon_ctl.rs +++ b/crates/uffs-client/src/daemon_ctl.rs @@ -262,6 +262,11 @@ pub fn parse_pid_file(path: &std::path::Path) -> Option<(u32, u64, u64, String)> } /// Find the `uffs` CLI executable. +/// +/// The `$PATH` fallback carries the `.exe` extension on Windows so a bare +/// `uffs` can never be resolved to a legacy `uffs.com` via PATHEXT (`.COM` +/// precedes `.EXE`) if this path is ever handed to a shell / registry entry / +/// logged command rather than spawned directly. #[must_use] pub fn find_uffs_exe() -> PathBuf { if let Ok(exe) = std::env::current_exe() { @@ -277,7 +282,7 @@ pub fn find_uffs_exe() -> PathBuf { } } } - PathBuf::from("uffs") + PathBuf::from(if cfg!(windows) { "uffs.exe" } else { "uffs" }) } /// Find the `uffsd` daemon executable. @@ -285,7 +290,9 @@ pub fn find_uffs_exe() -> PathBuf { /// Search order: /// 1. If the current binary is already `uffsd`, return it. /// 2. Look for `uffsd` / `uffsd.exe` next to the current binary. -/// 3. Fall back to bare `uffsd` (rely on `$PATH`). +/// 3. Fall back to the platform binary name `uffsd.exe` / `uffsd` on `$PATH` — +/// always `.exe`-qualified on Windows so a bare `uffsd` can never resolve to +/// a legacy `.com` via PATHEXT if handed to a shell. #[must_use] pub(crate) fn find_daemon_exe() -> PathBuf { if let Ok(exe) = std::env::current_exe() { @@ -301,7 +308,7 @@ pub(crate) fn find_daemon_exe() -> PathBuf { } } } - PathBuf::from("uffsd") + PathBuf::from(if cfg!(windows) { "uffsd.exe" } else { "uffsd" }) } #[cfg(test)] From ed01d389ca1dab427dda26472a319a00fe8bfe00 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:09:47 -0700 Subject: [PATCH 08/40] fix(cli): uninstall drops the legacy C++ uffs.exe GUI binary (PE subsystem check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a machine that still has the predecessor C++ UFFS installed, the deep sweep listed its uffs.exe copies and — worse — version_strays ran each with --version, launching a GUI window per copy (the slow, 'CPP version keeps popping up' behaviour). The C++ product is a Windows GUI app; our Rust CLI is a console app. Read the PE Optional-Header Subsystem field (headers only, never executing the file): if a uffs.exe is a GUI-subsystem binary, drop it from the strays before probing. Only uffs.exe collides with the predecessor; the other family names are Rust-only and untouched. Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../uffs-cli/src/commands/uninstall/sweep.rs | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index e307caffd..55b657624 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -52,15 +52,65 @@ pub(crate) struct StrayHit { pub(crate) fn version_strays(paths: Vec) -> Vec { paths .into_iter() - .map(|path| { + .filter_map(|path| { + // The legacy C++ `uffs.exe` is a Windows GUI app — a different + // product, not our console CLI. Drop it: running it with + // `--version` pops a window and is slow, and it is not ours to list. + if is_legacy_gui_uffs(&path) { + return None; + } let version = is_probeable_binary(&path) .then(|| crate::commands::update::binaries::probe_version(&path)) .flatten(); - StrayHit { path, version } + Some(StrayHit { path, version }) }) .collect() } +/// `IMAGE_SUBSYSTEM_WINDOWS_GUI` — a windowed app with no console. +const IMAGE_SUBSYSTEM_WINDOWS_GUI: u16 = 2; + +/// Whether `path` is the legacy C++ `uffs.exe`: named `uffs.exe` *and* built as +/// a Windows **GUI**-subsystem binary (our Rust CLI is a console app). Only +/// `uffs.exe` collides with the predecessor product — the other family names +/// are Rust-only, so they are never GUI-filtered. +fn is_legacy_gui_uffs(path: &Path) -> bool { + let is_uffs_exe = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("uffs.exe")); + is_uffs_exe && pe_subsystem(path) == Some(IMAGE_SUBSYSTEM_WINDOWS_GUI) +} + +/// Read a PE image's Optional-Header `Subsystem` field (2 = GUI, 3 = console) +/// without running it — only the headers are read. `None` on any read/parse +/// failure or a non-PE file. The `Subsystem` field sits at offset 68 of the +/// Optional Header in both PE32 and PE32+. +fn pe_subsystem(path: &Path) -> Option { + use std::io::{Read as _, Seek as _, SeekFrom}; + + let mut file = std::fs::File::open(path).ok()?; + let mut dos = [0_u8; 64]; + file.read_exact(&mut dos).ok()?; + if &dos[0..2] != b"MZ" { + return None; + } + // `e_lfanew` (offset to the PE header) lives at 0x3C in the DOS header. + let pe_off = u64::from(u32::from_le_bytes([dos[60], dos[61], dos[62], dos[63]])); + let mut sig = [0_u8; 4]; + file.seek(SeekFrom::Start(pe_off)).ok()?; + file.read_exact(&mut sig).ok()?; + if &sig != b"PE\0\0" { + return None; + } + // Optional Header starts after the 4-byte signature + 20-byte COFF header; + // `Subsystem` is at +68 within it. + file.seek(SeekFrom::Start(pe_off + 4 + 20 + 68)).ok()?; + let mut subsystem = [0_u8; 2]; + file.read_exact(&mut subsystem).ok()?; + Some(u16::from_le_bytes(subsystem)) +} + /// Whether `path` names an executable we can run `--version` on, rather than a /// UFFS data file (`*.uffs` cache / `*.cursor`) that has no version. fn is_probeable_binary(path: &Path) -> bool { From 5e6ac846121cb4e7c7ca549a37d11d9aa4a3a1e4 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:13:27 -0700 Subject: [PATCH 09/40] feat(cli): uninstall prints the running build's version + commit at the top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'uffs () — uninstall' header to the dry-run and live output, so any captured run is unambiguously tied to the exact binary (the same stamp `uffs --version` shows). Makes a stale-binary run obvious at a glance. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 1 + crates/uffs-cli/src/commands/uninstall/render.rs | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 9f2772bc3..dc1d84a52 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -77,6 +77,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } + render::print_run_header(); render::print_resolution_table(&resolved); render::print_inventory(&inventory); render::print_plan(&removal_plan); diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 8a3e9d84a..fc8c2ceb5 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -14,6 +14,18 @@ use super::resolve_order::{ResolutionState, StemResolution}; #[cfg(windows)] use super::sweep::StrayHit; +/// Print the running build's version + git commit at the top of an uninstall +/// run, so a dry-run or live log is unambiguously tied to the exact binary that +/// produced it (the same stamp `uffs --version` shows). +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_run_header() { + println!( + "uffs {} ({}) — uninstall\n", + env!("CARGO_PKG_VERSION"), + option_env!("UFFS_GIT_SHA").unwrap_or("unknown") + ); +} + /// 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")] From 8f7a01c3fe9e34c4246ad9f8344a5d041698e4dd Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:14:43 -0700 Subject: [PATCH 10/40] fix(cli): uninstall drive-coverage waits long enough + shows index progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live 7-drive load took ~2.5 min but the readiness poll capped at 120 s, so it returned at 6/7 drives and the sweep searched a not-yet-ready index — missing the still-loading drive (D:). Raise the cap to 600 s and print 'indexing for the sweep: N/M drives ready...' as drives come online, so the wait covers a real cold multi-drive index and never looks like a hang. Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 552e9f2bc..174d40b60 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -19,8 +19,11 @@ use uffs_client::connect_sync::UffsClientSync; use uffs_mft::platform::{DriveLetter, detect_ntfs_drives}; /// How long to wait for newly-requested drives to finish loading before the -/// sweep runs. A best-effort cap — a slow HDD index may still be in flight. -const INDEX_WAIT: core::time::Duration = core::time::Duration::from_secs(120); +/// sweep runs. Generous because a cold multi-drive index is genuinely slow +/// (millions of records per volume); the previous 120 s cap expired mid-load on +/// a 7-drive system (~2.5 min), so the sweep searched a not-yet-ready index and +/// missed the still-loading drive. +const INDEX_WAIT: core::time::Duration = core::time::Duration::from_secs(600); /// Ensure the daemon covers every NTFS drive before the deep sweep, offering to /// start it and index the missing drives. `confirm` prompts the user (returns @@ -90,17 +93,32 @@ const POLL_INTERVAL: core::time::Duration = core::time::Duration::from_millis(50 /// deadline, after which the sweep proceeds with whatever is loaded. fn wait_until_loaded(client: &mut UffsClientSync, wanted: &[DriveLetter]) { let deadline = std::time::Instant::now() + INDEX_WAIT; + let mut last_ready = usize::MAX; loop { - let all_loaded = client.status_drives().is_ok_and(|resp| { - wanted.iter().all(|drive| { - resp.drives - .iter() - .any(|row| row.letter == *drive && matches!(row.tier.as_str(), "hot" | "warm")) - }) + let ready = client.status_drives().map_or(0, |resp| { + wanted + .iter() + .filter(|drive| { + resp.drives.iter().any(|row| { + row.letter == **drive && matches!(row.tier.as_str(), "hot" | "warm") + }) + }) + .count() }); - if all_loaded || std::time::Instant::now() >= deadline { + // Progress feedback so a multi-minute index never looks like a hang. + if ready != last_ready { + print_index_progress(ready, wanted.len()); + last_ready = ready; + } + if ready == wanted.len() || std::time::Instant::now() >= deadline { return; } std::thread::sleep(POLL_INTERVAL); } } + +/// Print drive-index progress for [`wait_until_loaded`]. +#[expect(clippy::print_stdout, reason = "CLI progress output")] +fn print_index_progress(ready: usize, total: usize) { + println!(" indexing for the sweep: {ready}/{total} drives ready..."); +} From 92ff7062503f182646e33e5dcac1bc6dd429da2a Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:13:40 -0700 Subject: [PATCH 11/40] fix(cli): uninstall indexes drives with live progress + clearer prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3/#4 from the Windows test. The load RPC blocks until the daemon finishes every drive (loaded sequentially), which overran the client timeout on a 7-drive system — so it returned at 6/7 (missed D:) and no progress ever showed. Fire the load on a background connection and poll status_drives on this thread, printing 'indexing for the sweep: N/M drives ready...' as drives come online — the poll, not the RPC return, decides when the drives are searchable, so a background timeout is harmless and the sweep no longer searches a partial index. Also reword the coverage prompt to be less repetitive. Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 174d40b60..6cf4bcb60 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -61,22 +61,32 @@ pub(crate) fn ensure_drive_coverage(confirm: &mut dyn FnMut(&str) -> Result>() .join(", "); let prompt = format!( - "\nThe deep sweep searches every indexed drive. Not yet indexed: {list}.\n\ - Indexing builds the on-disk index cache for those drives (uses disk +\n\ - memory, and persists even under --dry-run). Index {list} now for a\n\ - complete sweep? [y/N] " + "\nThe deep sweep needs all {count} drives indexed first ({list}). This builds\n\ + an on-disk index cache (uses disk + memory, and is kept even on a dry run)\n\ + and can take a few minutes. Index them now? [y/N] " ); - if confirm(&prompt)? && client.load_drive_letters(&missing, false).is_ok() { - // Wait for the freshly-requested drives to become searchable — poll for - // readiness rather than a blind fixed wait, so the sweep never searches a - // still-parked shard (and returns as soon as they are loaded). + if confirm(&prompt)? { + // Fire the (blocking) load on a *background* connection so this thread can + // poll `status_drives` for live progress while the daemon works through + // the drives. The load RPC can exceed the client timeout on a big + // multi-drive index — but the poll, not the RPC return, decides when the + // drives are searchable, so a background timeout is harmless. + let to_load = missing.clone(); + let loader = std::thread::spawn(move || { + if let Ok(mut background) = UffsClientSync::connect_raw() { + // Best-effort: the poll below is the source of truth for "ready". + let _outcome = background.load_drive_letters(&to_load, false); + } + }); wait_until_loaded(&mut client, &missing); + let _joined = loader.join(); } Ok(()) } From ea0e51182bda4d306b71b0acb166bf3169cf09af Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:14:56 -0700 Subject: [PATCH 12/40] fix(cli): uninstall broker is optional when non-elevated + display polish #1: from a non-elevated terminal the broker (its LocalSystem service + process) cannot be stopped/removed, but the old gate refused the WHOLE uninstall. Mark the broker process as admin-only too, and instead of bailing, offer to skip the admin-only items and remove everything else now (or abort to re-run elevated). Adds RemovalPlan::drop_elevation_required. #2: show 'legacy' instead of '-' for versionless (old) binaries. #5: blank line between the 'found elsewhere' heading and the file list. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 20 ++++-- .../uffs-cli/src/commands/uninstall/plan.rs | 71 ++++++++++++++++++- .../uffs-cli/src/commands/uninstall/render.rs | 30 ++++---- 3 files changed, 99 insertions(+), 22 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index dc1d84a52..4ea9f57ad 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -70,7 +70,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // entries pointing at a *dedicated* UFFS dir are offered for removal — a // shared bin dir (~/bin, ~/.local/bin) we never created is left alone. let removable_path = analyze::removable_path_dirs(&report, &analyze::path_entries()); - let removal_plan = plan::build_plan(&report, &inventory, &parsed, &removable_path); + let mut removal_plan = plan::build_plan(&report, &inventory, &parsed, &removable_path); if parsed.json { render::print_json(&resolved, &inventory, &removal_plan); @@ -95,13 +95,19 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } - // M3 elevation gate (U-30): refuse before any effect when the plan needs - // privilege the current process lacks. `uffs_mft::platform::is_elevated` is - // cross-platform (Windows token check; Unix effective-uid 0), unlike the - // Windows-only `uffs_winsvc::is_elevated`. + // M3 elevation gate (U-30): the broker (its LocalSystem service + process) + // is the only admin-only part. Rather than refuse the whole uninstall when + // not elevated, offer to skip those and remove everything else now — or + // abort to re-run elevated. `uffs_mft::platform::is_elevated` is + // cross-platform (Windows token check; Unix effective-uid 0). 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"); + render::print_elevation_required(&removal_plan); + if confirm("\nSkip those (leave the broker installed) and continue? [y/N] ")? { + removal_plan.drop_elevation_required(); + render::print_broker_kept(); + } else { + bail!("re-run `uffs --uninstall` from an elevated terminal to remove the broker too"); + } } // Nothing to remove at all: no install in the standard locations, and the diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index 4633358d0..68bc50ca2 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -19,7 +19,7 @@ use super::args::{UninstallArgs, UninstallScope}; use super::inventory::{ArtifactKind, BrokerServiceState, Inventory}; #[cfg(windows)] use super::sweep::StrayHit; -use crate::commands::update::model::{Channel, DetectionReport, InstallRoot, Scope}; +use crate::commands::update::model::{Channel, Component, DetectionReport, InstallRoot, Scope}; /// The `WinGet` package id UFFS publishes under. pub(crate) const WINGET_PACKAGE_ID: &str = "SkyLLC.UFFS"; @@ -182,6 +182,16 @@ impl RemovalPlan { self.items().any(|item| item.needs_elevation) } + /// Drop every item that needs Administrator (the broker service + its + /// process), removing any group left empty. Lets a non-elevated run remove + /// everything it *can* and leave the broker for an elevated re-run. + pub(crate) fn drop_elevation_required(&mut self) { + for group in &mut self.groups { + group.items.retain(|item| !item.needs_elevation); + } + self.groups.retain(|group| !group.items.is_empty()); + } + /// Number of items across all groups. pub(crate) fn item_count(&self) -> usize { self.groups.iter().map(|group| group.items.len()).sum() @@ -228,7 +238,9 @@ pub(crate) fn build_plan( component: process.component.label().to_owned(), pid: process.pid, }, - needs_elevation: false, + // The broker runs as LocalSystem (the Windows service), so stopping + // it needs Administrator; the daemon / MCP are user-owned and do not. + needs_elevation: matches!(process.component, Component::Broker), scope: ItemScope::Any, bytes: 0, }) @@ -596,6 +608,61 @@ mod tests { ))); } + #[test] + fn drop_elevation_required_removes_broker_keeps_the_rest() { + let report = DetectionReport { + roots: Vec::new(), + running: vec![ + RunningProcess { + component: Component::Broker, + pid: 11, + image_path: None, + command_line: None, + version: None, + }, + RunningProcess { + component: Component::Daemon, + pid: 22, + image_path: None, + command_line: None, + version: None, + }, + ], + }; + // Broker service installed -> an admin-only RemoveService item, plus the + // broker process stop is admin-only; the daemon stop is not. + let mut plan = built( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &UninstallArgs::default(), + ); + assert!( + plan.requires_elevation(), + "broker service + process need admin" + ); + + plan.drop_elevation_required(); + assert!(!plan.requires_elevation(), "admin-only items were dropped"); + assert!( + !has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + )), + "the broker service item is gone" + ); + let stop_pids: Vec = plan + .items() + .filter_map(|item| { + if let PlanTarget::StopProcess { pid, .. } = &item.target { + Some(*pid) + } else { + None + } + }) + .collect(); + assert_eq!(stop_pids, vec![22], "only the daemon stop survives"); + } + #[test] #[cfg(windows)] fn stray_plan_is_one_group_of_unprivileged_delete_file_items() { diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index fc8c2ceb5..a473496a6 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -43,7 +43,7 @@ pub(crate) fn print_resolution_table(stems: &[StemResolution]) { ResolutionState::Shadowed if copy.on_search_path => "shadowed", ResolutionState::Shadowed => "off-path", }; - let version = copy.version.as_deref().unwrap_or("-"); + let version = copy.version.as_deref().unwrap_or("legacy"); println!( " {state:<8} {version:<9} {channel:<9} {scope:<7} {dir}", channel = copy.channel.label(), @@ -110,22 +110,26 @@ pub(crate) fn print_plan(plan: &RemovalPlan) { ); } -/// 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:"); +/// List the admin-only items (the broker service + its process) before the +/// non-elevated keep-or-elevate choice (U-30). +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_elevation_required(plan: &RemovalPlan) { + println!("\nThese items need Administrator (the broker runs as LocalSystem):"); for group in &plan.groups { for item in &group.items { if item.needs_elevation { - eprintln!(" - {}", item.target.describe()); + println!(" - {}", item.target.describe()); } } } - eprintln!( - "\nRe-run with elevated privileges (sudo on Linux/macOS, an elevated \ - shell on Windows):\n uffs --uninstall" +} + +/// Note printed when the user keeps the broker and continues non-elevated. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_broker_kept() { + println!( + "Leaving the broker installed. Re-run `uffs --uninstall` from an elevated \ + terminal to remove it." ); } @@ -141,10 +145,10 @@ pub(crate) fn print_strays(strays: &[StrayHit]) { println!( "\nAlso found elsewhere (deep sweep), outside the standard install locations.\n\ These are removed only if you confirm a separate prompt below (one may be a\n\ - copy you placed yourself):" + copy you placed yourself):\n" ); for stray in strays { - let version = stray.version.as_deref().unwrap_or("-"); + let version = stray.version.as_deref().unwrap_or("legacy"); println!(" {version:<9} {}", stray.path.display()); } } From 64810871b64810ec9beca58910a88c68619b2455 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:43:44 -0700 Subject: [PATCH 13/40] fix(cli): uninstall always indexes drives for the deep sweep (no prompt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indexing every NTFS drive is a non-elevated, non-destructive read that the deep sweep requires, so it should just happen — not be a [y/N] choice. Drop the prompt: ensure_drive_coverage now always loads any not-yet-indexed drives (with the live N/M progress) and no longer takes a confirm callback. This also removes the elevated-vs-non-elevated output divergence: the runs only differed because one had drives already loaded (no prompt) and the other didn't (prompt). Now both just index what's missing. Windows-only module. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 71 ++++++++----------- crates/uffs-cli/src/commands/uninstall/mod.rs | 9 ++- 2 files changed, 34 insertions(+), 46 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 6cf4bcb60..a19521269 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -14,7 +14,6 @@ #![cfg(windows)] -use anyhow::Result; use uffs_client::connect_sync::UffsClientSync; use uffs_mft::platform::{DriveLetter, detect_ntfs_drives}; @@ -25,24 +24,21 @@ use uffs_mft::platform::{DriveLetter, detect_ntfs_drives}; /// missed the still-loading drive. const INDEX_WAIT: core::time::Duration = core::time::Duration::from_secs(600); -/// Ensure the daemon covers every NTFS drive before the deep sweep, offering to -/// start it and index the missing drives. `confirm` prompts the user (returns -/// their yes/no). Returns `Ok(())` whether or not coverage was completed — the -/// caller sweeps regardless. +/// Ensure the daemon covers every NTFS drive before the deep sweep: connect +/// (auto-starting the daemon if needed) and index any drives not yet loaded. /// -/// # Errors -/// -/// Propagates only a failure of the `confirm` callback itself; daemon/RPC -/// failures are swallowed (best-effort coverage). -pub(crate) fn ensure_drive_coverage(confirm: &mut dyn FnMut(&str) -> Result) -> Result<()> { +/// Indexing is a non-elevated, non-destructive read the sweep requires, so it +/// **always runs — no prompt**. Best-effort: a missing daemon or RPC failure +/// just means the sweep covers whatever is already indexed. +pub(crate) fn ensure_drive_coverage() { let all = detect_ntfs_drives(); if all.is_empty() { - return Ok(()); + return; } // `connect()` auto-starts the daemon if it is not already running. let Ok(mut client) = UffsClientSync::connect() else { // Could not reach or start a daemon: nothing to cover, sweep as-is. - return Ok(()); + return; }; let indexed: Vec = client .drives() @@ -59,36 +55,29 @@ pub(crate) fn ensure_drive_coverage(confirm: &mut dyn FnMut(&str) -> Result>() - .join(", "); - let prompt = format!( - "\nThe deep sweep needs all {count} drives indexed first ({list}). This builds\n\ - an on-disk index cache (uses disk + memory, and is kept even on a dry run)\n\ - and can take a few minutes. Index them now? [y/N] " - ); - if confirm(&prompt)? { - // Fire the (blocking) load on a *background* connection so this thread can - // poll `status_drives` for live progress while the daemon works through - // the drives. The load RPC can exceed the client timeout on a big - // multi-drive index — but the poll, not the RPC return, decides when the - // drives are searchable, so a background timeout is harmless. - let to_load = missing.clone(); - let loader = std::thread::spawn(move || { - if let Ok(mut background) = UffsClientSync::connect_raw() { - // Best-effort: the poll below is the source of truth for "ready". - let _outcome = background.load_drive_letters(&to_load, false); - } - }); - wait_until_loaded(&mut client, &missing); - let _joined = loader.join(); + return; } - Ok(()) + print_index_intro(missing.len()); + // Fire the (blocking) load on a *background* connection so this thread can + // poll `status_drives` for live progress while the daemon works through the + // drives. The load RPC can exceed the client timeout on a big multi-drive + // index — but the poll, not the RPC return, decides when the drives are + // searchable, so a background timeout is harmless. + let to_load = missing.clone(); + let loader = std::thread::spawn(move || { + if let Ok(mut background) = UffsClientSync::connect_raw() { + // Best-effort: the poll below is the source of truth for "ready". + let _outcome = background.load_drive_letters(&to_load, false); + } + }); + wait_until_loaded(&mut client, &missing); + let _joined = loader.join(); +} + +/// Intro line printed before the per-drive index progress. +#[expect(clippy::print_stdout, reason = "CLI progress output")] +fn print_index_intro(count: usize) { + println!("\nIndexing {count} drive(s) for the deep sweep (this can take a few minutes):"); } /// Poll interval while waiting for requested drives to finish loading. diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 4ea9f57ad..998d12d21 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -232,11 +232,10 @@ fn platform_stray_plan(parsed: &UninstallArgs, removal_plan: &RemovalPlan) -> Re if parsed.no_deep_sweep { return RemovalPlan::default(); } - // Ensuring coverage may start the daemon / index drives — non-destructive, - // so it runs even under --dry-run to make the preview accurate. - if let Err(err) = coverage::ensure_drive_coverage(&mut |prompt| confirm(prompt)) { - render::print_journal_warning(&err); - } + // Indexing every drive is a non-elevated, non-destructive read the sweep + // needs, so it always runs (no prompt) — including under --dry-run, to make + // the preview accurate. + coverage::ensure_drive_coverage(); let known = plan_dirs(removal_plan); let mut search = sweep::DaemonSearch; let strays = sweep::version_strays(sweep::find_strays(&mut search, &known).unwrap_or_default()); From ad94b1d30dc21240687e41d2618fd3e59b9d8fa2 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:53:24 -0700 Subject: [PATCH 14/40] feat(cli): uninstall removes the full workspace binary set, not just the core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live run left ~15 dev/diagnostic binaries in ~/bin untouched (analyze-diff, dump-mft-records, gen-hooks, uffs-bench, uffs-ci-pipeline, …) — a from-source / cargo install build drops them next to the core set. Add them to EXTRA_BINARY_STEMS so the install-dir sweep removes them. Refactor the deep sweep to derive its search patterns + family filter from the shared family set (KNOWN_BINARIES + EXTRA_BINARY_STEMS) instead of a second hardcoded list — so adding a binary in one place now updates both the install-dir removal and the cross-drive sweep. None of these are managed by --update, so KNOWN_BINARIES (the update set) is untouched. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/analyze.rs | 26 ++++++++-- .../uffs-cli/src/commands/uninstall/sweep.rs | 51 +++++++++---------- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/analyze.rs b/crates/uffs-cli/src/commands/uninstall/analyze.rs index 1f5d01cad..fb7e329f4 100644 --- a/crates/uffs-cli/src/commands/uninstall/analyze.rs +++ b/crates/uffs-cli/src/commands/uninstall/analyze.rs @@ -13,11 +13,13 @@ use std::path::{Path, PathBuf}; use super::resolve_order::Candidate; use crate::commands::update::model::{BinaryInfo, Channel, DetectionReport, InstallRoot}; -/// Binary stems UFFS used in the past (retired names) or for optional members -/// (the TUI/GUI that moved to the products repo). None are in the current -/// `KNOWN_BINARIES`, but they linger in an install root from an old build, so -/// uninstall sweeps any that exist (idempotent — absent ones are skipped). +/// Binary stems beyond the core `KNOWN_BINARIES` that an install root may hold: +/// retired names, optional members, and the workspace dev/diagnostic tooling. +/// None are managed by `--update`, but a from-source / `cargo install` build +/// drops them next to the core set, so uninstall sweeps any that exist +/// (idempotent — absent ones are skipped). pub(crate) const EXTRA_BINARY_STEMS: &[&str] = &[ + // Retired / optional names. "uffs-tui", // optional member (moved to uffs-products) "uffs-gui", // optional member (moved to uffs-products) "uffs-daemon", // retired -> uffsd @@ -26,6 +28,22 @@ pub(crate) const EXTRA_BINARY_STEMS: &[&str] = &[ "uffs_tui", // ancient underscore naming "uffs_gui", // ancient underscore naming "uffs_mft", // ancient underscore naming + // Dev / diagnostic / tooling binaries (workspace bin targets). + "uffs-bench", + "uffs-ci-pipeline", + "analyze-diff", + "analyze-mft-parents", + "compare-raw-mft", + "compare-scan-parity", + "cross-check-mft-reference", + "dump-mft-extents", + "dump-mft-records", + "inspect-mft-record-flow", + "scan-mft-magic", + "verify-iocp-capture", + "manifest-audit", + "gen-hooks", + "gen-workflow", ]; /// Add any [`EXTRA_BINARY_STEMS`] that actually exist in an unmanaged / diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index 55b657624..076e291e4 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -15,19 +15,20 @@ use std::path::{Path, PathBuf}; use anyhow::Result; -/// 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", -]; +/// UFFS cache/cursor data-file patterns the sweep searches for. The executable +/// patterns are derived from the shared family set (see [`family_stems`]). +const CACHE_PATTERNS: &[&str] = &["*_compact.uffs", "*_usn.cursor"]; + +/// Every UFFS family executable stem — the core managed set plus the +/// retired/optional/dev-tooling names. Single source of truth shared with the +/// install-dir sweep ([`super::analyze::EXTRA_BINARY_STEMS`]) so adding a +/// binary in one place updates both the install-dir removal and the deep sweep. +fn family_stems() -> impl Iterator { + crate::commands::update::binaries::KNOWN_BINARIES + .iter() + .copied() + .chain(super::analyze::EXTRA_BINARY_STEMS.iter().copied()) +} /// A search backend, injected so the dedup logic is testable without a daemon. pub(crate) trait Search { @@ -124,8 +125,10 @@ fn is_probeable_binary(path: &Path) -> bool { /// 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)? { + let exe_patterns = family_stems().map(|stem| format!("{stem}.exe")); + let patterns = exe_patterns.chain(CACHE_PATTERNS.iter().map(|pattern| (*pattern).to_owned())); + for pattern in patterns { + for hit in search.find(&pattern)? { if is_family_artifact(&hit) && !is_under_any(&hit, known_dirs) { strays.push(hit); } @@ -162,20 +165,9 @@ fn is_family_artifact(path: &Path) -> bool { let Some(stem) = lower.strip_suffix(".exe") else { return false; }; - FAMILY_EXE_STEMS.contains(&stem) || stem.starts_with("uffs-tui") || stem.starts_with("uffs-gui") + family_stems().any(|family| family.eq_ignore_ascii_case(stem)) } -/// Exact `*.exe` family stems the deep sweep removes; the optional `uffs-tui*` -/// / `uffs-gui*` members are matched by prefix in [`is_family_artifact`]. -const FAMILY_EXE_STEMS: &[&str] = &[ - "uffs", - "uffsd", - "uffsmcp", - "uffs-broker", - "uffs-update", - "uffs-mft", -]; - /// 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 { @@ -274,7 +266,10 @@ mod tests { assert!(is_family_artifact(Path::new(r"C:\x\uffs.exe"))); assert!(is_family_artifact(Path::new(r"C:\x\uffsd.exe"))); assert!(is_family_artifact(Path::new(r"C:\x\uffs-broker.exe"))); - assert!(is_family_artifact(Path::new(r"C:\x\uffs-tui-x86.exe"))); + assert!(is_family_artifact(Path::new(r"C:\x\uffs-tui.exe"))); + // Dev/diagnostic tooling is part of the family set now. + assert!(is_family_artifact(Path::new(r"C:\x\dump-mft-records.exe"))); + assert!(is_family_artifact(Path::new(r"C:\x\uffs-ci-pipeline.exe"))); assert!(is_family_artifact(Path::new(r"C:\x\drive_c_compact.uffs"))); assert!(is_family_artifact(Path::new(r"C:\x\journal_usn.cursor"))); // Noise the daemon's substring search also returns — must be dropped. From 7d22fb0510273dfd5163b9a319fba8c6b8e659be Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:35:58 -0700 Subject: [PATCH 15/40] fix(cli): uninstall gathers decisions up front, runs once, defers the running binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the live runs: 1. Decisions up front, no post-removal prompts: the broker keep/skip (elevation gate) and the deep-sweep strays opt-in are both decided before the single 'Proceed with removal?' go, then everything executes once into one combined outcome — so the summary + retry hint print once, not per-phase. 2. Platform-correct failure hint: elevation only exists on Windows (the broker is a LocalSystem service); a non-Windows uninstall is all user-land, so it no longer suggests 'sudo' there — a failure is a file in use. 3. The chicken-and-egg self-delete: the OS locks a running image, so deleting uffs.exe / uffs-update.exe in place is the 'access denied' seen in the live run. SystemEffects now skips the running self-binaries (matched verbatim- stripped, case-insensitive) and the existing spawned-cmd schedule_self_delete removes them after exit — the same deferred-delete installers (NSIS/Inno) use. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/effects.rs | 47 +++++++++--- crates/uffs-cli/src/commands/uninstall/mod.rs | 73 +++++++++++-------- .../uffs-cli/src/commands/uninstall/remove.rs | 12 +++ .../uffs-cli/src/commands/uninstall/render.rs | 23 +++++- 4 files changed, 114 insertions(+), 41 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index d550f5ac3..5b4d8369a 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -17,13 +17,29 @@ 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; +/// The production effects implementation. Carries the running self-binaries so +/// they can be skipped in place — the OS locks a running image, so deleting it +/// directly fails; [`schedule_self_delete`] removes them after this process +/// exits instead. +pub(crate) struct SystemEffects { + /// Absolute paths of the running self-binaries to skip in-place deletes. + self_paths: Vec, +} impl SystemEffects { - /// Construct the live effects sink. - pub(crate) const fn new() -> Self { - Self + /// Construct the live effects sink, told which running self-binaries to + /// skip in-place (they are deferred to [`schedule_self_delete`]). + pub(crate) const fn new(self_paths: Vec) -> Self { + Self { self_paths } + } + + /// Whether `path` is one of the running self-binaries (case-insensitive, + /// matching the verbatim-stripped form the plan carries). + fn is_self(&self, path: &Path) -> bool { + let target = path.to_string_lossy(); + self.self_paths + .iter() + .any(|self_path| self_path.to_string_lossy().eq_ignore_ascii_case(&target)) } } @@ -39,6 +55,10 @@ impl Effects for SystemEffects { fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> { for stem in stems { let path = dir.join(exe_file_name(stem)); + // A running self-binary can't be deleted in place — defer it. + if self.is_self(&path) { + continue; + } remove_file_if_present(&path) .with_context(|| format!("removing {}", path.display()))?; } @@ -51,6 +71,10 @@ impl Effects for SystemEffects { #[cfg(windows)] fn delete_file(&mut self, path: &Path) -> Result<()> { + // A running self-binary can't be deleted in place — defer it. + if self.is_self(path) { + return Ok(()); + } remove_file_if_present(path).with_context(|| format!("removing {}", path.display())) } @@ -280,11 +304,16 @@ mod tests { std::fs::write(base.join(exe_file_name(stem)), b"binary").unwrap(); } - let mut effects = SystemEffects::new(); - // Deletes the named binaries... + // The second stem is treated as the running self-binary — it must be + // skipped (left for the deferred self-delete), not removed in place. + let self_path = base.join(exe_file_name("uffsd")); + let mut effects = SystemEffects::new(vec![self_path.clone()]); effects.delete_binaries(&base, &stems).unwrap(); - assert!(!base.join(exe_file_name("uffs")).exists()); - assert!(!base.join(exe_file_name("uffsd")).exists()); + assert!( + !base.join(exe_file_name("uffs")).exists(), + "non-self binary removed" + ); + assert!(self_path.exists(), "running self-binary skipped (deferred)"); // ...and is idempotent on already-absent files. effects.delete_binaries(&base, &stems).unwrap(); diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 998d12d21..70163edcd 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -116,8 +116,19 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } - // M4 consent (U-21): unless --yes, require explicit confirmation (default No) - // before any destructive effect. Declining aborts the whole uninstall. + // Gather every decision UP FRONT, then execute once — never ask after + // removal has started. The broker keep/skip was decided at the elevation + // gate above. On Windows, decide the deep-sweep strays here too (a separate + // opt-in: a copy you placed yourself may be among them). + #[cfg(windows)] + let remove_strays = !stray_plan.is_empty() + && (parsed.assume_yes + || confirm(&format!( + "\nAlso remove the {} file(s) found elsewhere (listed above)? [y/N] ", + stray_plan.item_count() + ))?); + + // M4 consent (U-21): the final go. Declining aborts the whole uninstall. if !removal_plan.is_empty() && !parsed.assume_yes && !confirm("\nProceed with removal? [y/N] ")? { print_aborted(); @@ -131,39 +142,38 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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(); + // The running uffs.exe (+ uffs-update.exe) are locked by the OS, so the + // executor must SKIP them in place — deleting them directly is the "access + // denied" the user hits — and a deferred [`schedule_self_delete`] removes + // them after this process exits. + let self_paths = self_binaries(); + + // M4 execute (U-40..42): run the plan(s) once against the live effects sink, + // accumulating a single outcome so the summary + retry hint print once. + let mut effects = effects::SystemEffects::new(self_paths.clone()); + let mut outcome = remove::RemovalOutcome::default(); if !removal_plan.is_empty() { - let outcome = remove::execute(&removal_plan, &mut effects); + outcome.absorb(remove::execute(&removal_plan, &mut effects)); + } + #[cfg(windows)] + if remove_strays { + outcome.absorb(remove::execute(&stray_plan, &mut effects)); + } + if !outcome.is_empty() { render::print_outcome(&outcome); } - - // Strays found outside the standard locations get a SEPARATE confirmation - // (one may be a copy the user placed themselves), then are removed - // best-effort. `--yes` covers both prompts. Windows-only — see - // `platform_stray_plan`; off Windows `stray_plan` is always empty. #[cfg(windows)] - if !stray_plan.is_empty() { - let approved = parsed.assume_yes - || confirm(&format!( - "\nAlso remove the {} file(s) found elsewhere (listed above)? [y/N] ", - stray_plan.item_count() - ))?; - if approved { - let stray_outcome = remove::execute(&stray_plan, &mut effects); - render::print_outcome(&stray_outcome); - } else { - render::print_strays_kept(); - } + if !stray_plan.is_empty() && !remove_strays { + render::print_strays_kept(); } - // 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 self-delete (U-80): finish the deferred delete of the running + // self-binaries the executor skipped. If even scheduling fails, say so. + if !self_paths.is_empty() { + render::print_self_delete_scheduled(&self_paths); + 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 @@ -188,9 +198,12 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { /// 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 { + let Ok(raw_exe) = std::env::current_exe() else { return Vec::new(); }; + // Match the verbatim-stripped form the plan carries, so the executor's + // self-skip and the verify exclusion compare equal. + let exe = crate::commands::update::strip_verbatim_prefix(raw_exe); let mut paths = vec![exe.clone()]; if let Some(dir) = exe.parent() { let updater = if cfg!(windows) { diff --git a/crates/uffs-cli/src/commands/uninstall/remove.rs b/crates/uffs-cli/src/commands/uninstall/remove.rs index cebc1a02d..3cbfa6dc1 100644 --- a/crates/uffs-cli/src/commands/uninstall/remove.rs +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -62,6 +62,18 @@ impl RemovalOutcome { self.results.push((description, status)); } + /// Fold another outcome's results into this one, so the main plan and the + /// stray removal report as a single combined outcome (one summary line, one + /// retry hint) rather than two. + pub(crate) fn absorb(&mut self, other: Self) { + self.results.extend(other.results); + } + + /// Whether nothing was executed (no items recorded). + pub(crate) const fn is_empty(&self) -> bool { + self.results.is_empty() + } + /// Number of items that completed. pub(crate) fn done_count(&self) -> usize { self.results diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index a473496a6..b17e220a9 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -175,6 +175,16 @@ pub(crate) fn print_journal_warning(error: &anyhow::Error) { eprintln!("note: uninstall progress marker could not be updated ({error:#})."); } +/// Note that the running self-binaries are deferred to a post-exit delete +/// (the OS locks a running image, so they can't be removed in place). +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_self_delete_scheduled(paths: &[std::path::PathBuf]) { + println!("\nThe running UFFS binary is removed after this process exits:"); + for path in paths { + println!(" {}", path.display()); + } +} + /// 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) { @@ -215,9 +225,18 @@ pub(crate) fn print_outcome(outcome: &RemovalOutcome) { } } if !outcome.all_done() { + // Elevation only exists on Windows (the broker is a LocalSystem service); + // every non-Windows uninstall runs entirely in user-land, so a failure + // there is a file in use, never a privilege problem — no sudo hint. + #[cfg(windows)] + println!( + "\nSome items could not be removed — e.g. the broker, a LocalSystem service. \ + Re-run `uffs --uninstall` from an elevated (Administrator) terminal." + ); + #[cfg(not(windows))] println!( - "\nSome items could not be removed. Retry with elevated privileges \ - (sudo on Linux/macOS, an elevated shell on Windows)." + "\nSome items could not be removed (a file may be in use). Close anything \ + using them and re-run." ); } } From 1a4e6660f101b1ca417b74d24d7a620f0d4acc8d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:42:58 -0700 Subject: [PATCH 16/40] fix(cli): uninstall does not taskkill the broker (it is a service; sc handles it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the elevated run, 'broker (pid 9064)' failed with exit 128: it was a StopProcess item executed via taskkill /F, but the broker is a LocalSystem service — taskkill can't stop it (and even forced, the SCM restarts it). The RemoveService item already stops + deletes it the right way (uffs_winsvc::stop + sc delete). Filter the broker out of the Processes group so it is never taskkill'd. Only the user-owned daemon / MCP remain there (no admin needed); the broker is handled solely by RemoveService. Removes the guaranteed-failure line from the outcome. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/plan.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index 68bc50ca2..37c27bbc9 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -229,18 +229,21 @@ pub(crate) fn build_plan( push_group(&mut groups, "Services", vec![item], args.scope); } - // 2. Processes (stopped before their binaries are deleted). + // 2. Processes (stopped before their binaries are deleted). The broker is a + // LocalSystem **service** — `taskkill` can't stop it (returns exit 128, and + // the SCM would just restart it), so it is never a StopProcess item; the + // RemoveService item above stops + deletes it via `sc`. The daemon / MCP are + // ordinary user-owned processes, so a plain stop applies and needs no admin. let processes: Vec = report .running .iter() + .filter(|process| !matches!(process.component, Component::Broker)) .map(|process| PlanItem { target: PlanTarget::StopProcess { component: process.component.label().to_owned(), pid: process.pid, }, - // The broker runs as LocalSystem (the Windows service), so stopping - // it needs Administrator; the daemon / MCP are user-owned and do not. - needs_elevation: matches!(process.component, Component::Broker), + needs_elevation: false, scope: ItemScope::Any, bytes: 0, }) @@ -629,8 +632,9 @@ mod tests { }, ], }; - // Broker service installed -> an admin-only RemoveService item, plus the - // broker process stop is admin-only; the daemon stop is not. + // Broker service installed -> an admin-only RemoveService item. The + // broker *process* is filtered out (it's a service, stopped via sc, not + // taskkill); only the user-owned daemon stop remains, needing no admin. let mut plan = built( &report, &inventory(BrokerServiceState::Installed, 1024), From 11b91a05b9baef00cab81f5e8cd94951bc62695a Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:56:02 -0700 Subject: [PATCH 17/40] fix(cli): uninstall decides the broker/elevation up front, before the deep sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-elevated run should be told immediately that the broker needs Administrator — not after sitting through a multi-minute drive index + sweep. Move the elevation decision to right after the plan is shown, before platform_stray_plan: - Not elevated + broker installed: flag it and offer to continue (uninstall everything except the broker) or abort to re-run from an elevated terminal. - Elevated: skipped entirely — just remove everything (incl. the broker), and the running binary is self-deleted at the end as before. - Dry-run: only previews (the plan already marks the broker 'needs Administrator'). Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 70163edcd..c610548d6 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -82,6 +82,28 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { render::print_inventory(&inventory); render::print_plan(&removal_plan); + // M3 elevation (U-30): the broker (its LocalSystem service) is the only + // admin-only part. Decide it UP FRONT — *before* the slow deep sweep — so a + // non-elevated run is told immediately and isn't left to discover it at the + // end. An elevated run skips this and removes everything. Dry-run only + // previews (the plan already marks the broker "needs Administrator"). + // `uffs_mft::platform::is_elevated` is cross-platform (Windows token check; + // Unix effective-uid 0). + if !parsed.dry_run && removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() { + render::print_elevation_required(&removal_plan); + if confirm( + "\nRemoving these needs Administrator. Continue now and uninstall everything\n\ + ELSE, leaving them? (answering No aborts so you can re-run elevated) [y/N] ", + )? { + removal_plan.drop_elevation_required(); + render::print_broker_kept(); + } else { + bail!( + "aborted — re-run `uffs --uninstall` from an elevated (Administrator) terminal to remove everything" + ); + } + } + // M7 deep sweep: ask UFFS itself for stray family files elsewhere on the // live drives, version them, and build a separate plan removed only under // its own confirmation (one may be a copy the user placed themselves). This @@ -95,21 +117,6 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } - // M3 elevation gate (U-30): the broker (its LocalSystem service + process) - // is the only admin-only part. Rather than refuse the whole uninstall when - // not elevated, offer to skip those and remove everything else now — or - // abort to re-run elevated. `uffs_mft::platform::is_elevated` is - // cross-platform (Windows token check; Unix effective-uid 0). - if removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() { - render::print_elevation_required(&removal_plan); - if confirm("\nSkip those (leave the broker installed) and continue? [y/N] ")? { - removal_plan.drop_elevation_required(); - render::print_broker_kept(); - } else { - bail!("re-run `uffs --uninstall` from an elevated terminal to remove the broker too"); - } - } - // Nothing to remove at all: no install in the standard locations, and the // deep sweep found no strays. if removal_plan.is_empty() && stray_plan.is_empty() { From 318b2cb1448f4a4cee0c2ca9cc0015c49764f075 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:54:53 -0700 Subject: [PATCH 18/40] perf(uninstall): parallelize + timeout the deep-sweep version probes; anchor the query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deep sweep's post-query step ran ` --version` sequentially with no timeout, once per family hit. On a dev box (hundreds of `uffs*.exe` under `target/`) that took minutes, and any binary that doesn't cleanly handle `--version` (half-written artifact, something waiting on stdin) hung the whole sweep indefinitely. version_strays: - probe in parallel via a bounded `std::thread::scope` worker pool (cursor-stealing; no new dependency) - `probe_version_bounded`: stdin nulled, piped output, poll `try_wait` to a 2s deadline then `kill` — a hung binary goes unversioned instead of stalling DaemonSearch::find: - anchor each `stem.exe` query with `--name-only --ext exe` instead of a bare full-path substring. Measured 158 -> 46 hits for `uffs.exe`; identical to the exact-regex count, so it's lossless. Drops `.mui`/prefetch/ADS/path-substring noise at the daemon before rows cross the wire. Glob cache patterns untouched. Plus temporary `[sweep]` diagnostics (per-pattern raw/kept counts, phase timings, timeout count) routed through one `dbg_line` helper, to verify the live Windows run. To be removed once signed off. Co-Authored-By: Claude Opus 4.8 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 18 +- .../uffs-cli/src/commands/uninstall/sweep.rs | 187 ++++++++++++++++-- 2 files changed, 184 insertions(+), 21 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index c610548d6..effce0103 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -258,7 +258,23 @@ fn platform_stray_plan(parsed: &UninstallArgs, removal_plan: &RemovalPlan) -> Re coverage::ensure_drive_coverage(); let known = plan_dirs(removal_plan); let mut search = sweep::DaemonSearch; - let strays = sweep::version_strays(sweep::find_strays(&mut search, &known).unwrap_or_default()); + + let find_started = std::time::Instant::now(); + let candidates = sweep::find_strays(&mut search, &known).unwrap_or_default(); + sweep::dbg_line(&format!( + "found {} candidate file(s) in {:.2?} (after filtering)", + candidates.len(), + find_started.elapsed() + )); + + let probe_started = std::time::Instant::now(); + let strays = sweep::version_strays(&candidates); + sweep::dbg_line(&format!( + "versioned {} stray(s) in {:.2?}", + strays.len(), + probe_started.elapsed() + )); + render::print_strays(&strays); plan::build_stray_plan(&strays) } diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index 076e291e4..96f6c1ddc 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -11,10 +11,21 @@ //! backend ([`DaemonSearch`]) is best-effort (no daemon ⇒ no hits, never a //! hard failure). +use core::time::Duration; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::time::Instant; use anyhow::Result; +/// TEMPORARY uninstall deep-sweep diagnostics. Prints a `[sweep]` line to +/// stdout so we can see candidate counts / phase timings during the Windows +/// rollout. Remove once the sweep is signed off. +#[expect(clippy::print_stdout, reason = "temporary deep-sweep diagnostics")] +pub(crate) fn dbg_line(msg: &str) { + println!(" [sweep] {msg}"); +} + /// UFFS cache/cursor data-file patterns the sweep searches for. The executable /// patterns are derived from the shared family set (see [`family_stems`]). const CACHE_PATTERNS: &[&str] = &["*_compact.uffs", "*_usn.cursor"]; @@ -46,26 +57,136 @@ pub(crate) struct StrayHit { pub(crate) version: Option, } +/// Hard cap on a single `--version` probe. A stray that hangs (waits on stdin, +/// starts a service, is a half-written build artifact) must never stall the +/// whole sweep — it just goes unversioned. A healthy console binary returns in +/// well under this. +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); + /// Attach a version to each stray: probe `--version` on the executable hits and /// leave UFFS data files (`*_compact.uffs`, `*_usn.cursor`) unversioned. No -/// daemon needed — each binary is run directly (the same probe the standard -/// detection uses). -pub(crate) fn version_strays(paths: Vec) -> Vec { - paths - .into_iter() - .filter_map(|path| { - // The legacy C++ `uffs.exe` is a Windows GUI app — a different - // product, not our console CLI. Drop it: running it with - // `--version` pops a window and is slow, and it is not ours to list. - if is_legacy_gui_uffs(&path) { - return None; +/// daemon needed — each binary is run directly. +/// +/// Probes run **in parallel** (a small scoped-thread pool) with a **per-probe +/// timeout** — a dev box can hold hundreds of family `*.exe` under `target/`, +/// and probing them one at a time (or letting one hang) is what made the sweep +/// take minutes. +pub(crate) fn version_strays(paths: &[PathBuf]) -> Vec { + use core::sync::atomic::{AtomicUsize, Ordering}; + + if paths.is_empty() { + return Vec::new(); + } + // Probes are subprocess spawns (I/O bound), so a small fixed pool of workers + // pulling from a shared cursor beats sequential (minutes on a dev box with + // hundreds of `target/` binaries) without spawning one thread per path. + let worker_count = std::thread::available_parallelism() + .map_or(4, core::num::NonZeroUsize::get) + .min(paths.len()); + let next = AtomicUsize::new(0); + let timed_out = AtomicUsize::new(0); + + let mut strays: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = (0..worker_count) + .map(|_| { + scope.spawn(|| { + let mut local: Vec = Vec::new(); + loop { + let idx = next.fetch_add(1, Ordering::Relaxed); + let Some(path) = paths.get(idx) else { break }; + // The legacy C++ `uffs.exe` is a Windows GUI app — a + // different product, not our console CLI. Drop it: + // probing it pops a window, is slow, and it is not ours. + if is_legacy_gui_uffs(path) { + continue; + } + let version = if is_probeable_binary(path) { + match probe_version_bounded(path) { + ProbeOutcome::Version(version) => Some(version), + ProbeOutcome::TimedOut => { + timed_out.fetch_add(1, Ordering::Relaxed); + None + } + ProbeOutcome::None => None, + } + } else { + None + }; + local.push(StrayHit { + path: path.clone(), + version, + }); + } + local + }) + }) + .collect(); + handles + .into_iter() + .flat_map(|handle| handle.join().unwrap_or_default()) + .collect() + }); + // Worker order is non-deterministic; restore the sorted order for output. + strays.sort_by(|left, right| left.path.cmp(&right.path)); + let timed_out_count = timed_out.load(Ordering::Relaxed); + if timed_out_count > 0 { + dbg_line(&format!( + "{timed_out_count} probe(s) hit the {PROBE_TIMEOUT:?} timeout and were left unversioned" + )); + } + strays +} + +/// The result of a bounded `--version` probe. +enum ProbeOutcome { + /// A version string was parsed from the binary's output. + Version(String), + /// The binary did not exit within [`PROBE_TIMEOUT`] and was killed. + TimedOut, + /// The binary ran but produced no parseable version (or failed to spawn). + None, +} + +/// Probe `path --version` with a hard timeout, killing a process that overruns. +/// `--version` output is tiny, so reading it after exit cannot deadlock on a +/// full pipe. `stdin` is nulled so a binary that reads stdin can't block. +fn probe_version_bounded(path: &Path) -> ProbeOutcome { + use std::process::Stdio; + + let Ok(mut child) = std::process::Command::new(path) + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + else { + return ProbeOutcome::None; + }; + let deadline = Instant::now() + PROBE_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_status)) => break, + Ok(None) => { + if Instant::now() >= deadline { + let _kill = child.kill(); + let _wait = child.wait(); + return ProbeOutcome::TimedOut; + } + std::thread::sleep(Duration::from_millis(25)); } - let version = is_probeable_binary(&path) - .then(|| crate::commands::update::binaries::probe_version(&path)) - .flatten(); - Some(StrayHit { path, version }) - }) - .collect() + Err(_) => return ProbeOutcome::None, + } + } + let Ok(output) = child.wait_with_output() else { + return ProbeOutcome::None; + }; + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + if text.trim().is_empty() { + // Some tools print `--version` to stderr; fall back to it. + text = String::from_utf8_lossy(&output.stderr).into_owned(); + } + crate::commands::update::binaries::parse_version(&text) + .map_or(ProbeOutcome::None, ProbeOutcome::Version) } /// `IMAGE_SUBSYSTEM_WINDOWS_GUI` — a windowed app with no console. @@ -128,11 +249,20 @@ pub(crate) fn find_strays(search: &mut dyn Search, known_dirs: &[PathBuf]) -> Re let exe_patterns = family_stems().map(|stem| format!("{stem}.exe")); let patterns = exe_patterns.chain(CACHE_PATTERNS.iter().map(|pattern| (*pattern).to_owned())); for pattern in patterns { - for hit in search.find(&pattern)? { + let hits = search.find(&pattern)?; + let raw = hits.len(); + let mut kept = 0_usize; + for hit in hits { if is_family_artifact(&hit) && !is_under_any(&hit, known_dirs) { + kept += 1; strays.push(hit); } } + if raw > 0 { + dbg_line(&format!( + "pattern {pattern:<22} raw={raw:<5} kept={kept} (after exact-name + known-dir filter)" + )); + } } strays.sort(); strays.dedup(); @@ -194,14 +324,31 @@ impl Search for DaemonSearch { // multi-column CSV blob (which has no JSON `path` field — the original // bug, where a real multi-hit Windows sweep returned a blob and the // JSON `"path"`-key walk found nothing). - let args = vec![ + // + // `--name-only` anchors the match to the **filename**: a bare `uffs.exe` + // token is a full-path substring match, so it also returns files merely + // living under a path that contains "uffs.exe" (e.g. an `…\uffs.exe.bak\` + // dir). We only ever want files actually named like a family binary. + let mut args = vec![ pattern.to_owned(), "--files-only".to_owned(), + "--name-only".to_owned(), "--columns".to_owned(), "path".to_owned(), "--limit".to_owned(), "5000".to_owned(), ]; + // For a concrete `stem.exe` pattern (no glob), pin the extension too so + // the daemon drops `uffs.exe.mui` / prefetch `.pf` / ADS noise *before* + // shipping rows back — measured 158 -> 46 hits for `uffs.exe` on a dev + // box. Glob cache patterns (`*_compact.uffs`) already pin their own + // extension, so they are left as-is. + if !pattern.contains('*') + && let Some(ext) = Path::new(pattern).extension().and_then(OsStr::to_str) + { + args.push("--ext".to_owned()); + args.push(ext.to_owned()); + } let Ok(response) = client.search_cli(&args) else { return Ok(Vec::new()); }; @@ -324,7 +471,7 @@ mod tests { fn data_files_are_not_probed_for_a_version() { // Cache/cursor data files have no version and must not be executed; a // (nonexistent) binary path probes to None rather than panicking. - let strays = version_strays(vec![ + let strays = version_strays(&[ PathBuf::from("/x/drive_c_compact.uffs"), PathBuf::from("/x/journal_usn.cursor"), PathBuf::from("/x/definitely-not-here/uffs"), From 76b1de89175a971e3c9a7b74e8c62feddb47cb9e Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:34:20 -0700 Subject: [PATCH 19/40] feat(uninstall): redesign the discovered-binary table (one row/binary, header, plain labels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old output was two lines per binary (a `stem:` header + an indented row) with cryptic columns: ACTIVE/shadowed/off-path, a raw channel word (`unmanaged`), and a bare `-` scope. Redesign to a single aligned row per copy with a header and a STATUS legend: - ACTIVE -> `runs` (the copy a bare command executes, first on PATH); on-PATH-but-later -> `shadowed`; not-on-PATH -> `off PATH`. - Fold the channel + scope into one SOURCE column: `hand-placed` (was `unmanaged`), `dev build`, `winget (user)`/`winget (machine)` — scope only means something for winget, so the lone `-` is gone. - Columns are width-sized to header+cells; LOCATION is last / free-width. Display-only; resolution logic in resolve_order.rs is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../uffs-cli/src/commands/uninstall/render.rs | 114 +++++++++++++++--- 1 file changed, 95 insertions(+), 19 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index b17e220a9..ed4b99c85 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -13,6 +13,7 @@ use super::remove::{ItemStatus, RemovalOutcome}; use super::resolve_order::{ResolutionState, StemResolution}; #[cfg(windows)] use super::sweep::StrayHit; +use crate::commands::update::model::{Channel, Scope}; /// Print the running build's version + git commit at the top of an uninstall /// run, so a dry-run or live log is unambiguously tied to the exact binary that @@ -26,31 +27,106 @@ pub(crate) fn print_run_header() { ); } -/// Print the discovered-binary resolution table: for each stem, every copy in -/// OS search order, with the one a bare command runs flagged ACTIVE. +/// One flattened row of the resolution table (one per discovered copy), so each +/// binary is a single line rather than a stem header plus an indented row. +struct ResolutionRow { + /// Binary name (`uffs`, `uffs-mft`, …). + binary: String, + /// On-disk version, or `legacy` when it could not be read. + version: String, + /// PATH-resolution standing: `runs` / `shadowed` / `off PATH`. + status: &'static str, + /// Where the copy came from (`hand-placed`, `winget (user)`, `dev build`, + /// …). + source: String, + /// The directory the copy lives in. + location: String, +} + +/// Plain-language PATH-resolution standing of a copy: the one a bare command +/// runs (`runs`), a copy on PATH that another shadows (`shadowed`), or a copy +/// not on PATH at all (`off PATH`). +const fn status_label(state: ResolutionState, on_search_path: bool) -> &'static str { + match (state, on_search_path) { + (ResolutionState::Active, _) => "runs", + (ResolutionState::Shadowed, true) => "shadowed", + (ResolutionState::Shadowed, false) => "off PATH", + } +} + +/// Human "source" label: how the copy got there. Install scope (user/machine) +/// only means something for a `winget` install, so it is folded in there and +/// omitted from the hand-placed / dev-build cases (which is why the old table +/// showed a bare `-`). +fn source_label(channel: Channel, scope: Scope) -> String { + match channel { + Channel::WinGet => match scope { + Scope::User => "winget (user)".to_owned(), + Scope::Machine => "winget (machine)".to_owned(), + Scope::Unknown => "winget".to_owned(), + }, + Channel::Unmanaged => "hand-placed".to_owned(), + Channel::DevBuild => "dev build".to_owned(), + Channel::Unknown => "unknown".to_owned(), + } +} + +/// Print the discovered-binary resolution table: one aligned row per copy, with +/// a header and a STATUS legend. `runs` is the copy a bare command executes. #[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("legacy"); - println!( - " {state:<8} {version:<9} {channel:<9} {scope:<7} {dir}", - channel = copy.channel.label(), - scope = copy.scope.label(), - dir = copy.dir.display(), - ); - } + let rows: Vec = stems + .iter() + .flat_map(|stem| { + stem.copies.iter().map(move |copy| ResolutionRow { + binary: stem.stem.clone(), + version: copy.version.clone().unwrap_or_else(|| "legacy".to_owned()), + status: status_label(copy.state, copy.on_search_path), + source: source_label(copy.channel, copy.scope), + location: copy.dir.display().to_string(), + }) + }) + .collect(); + + // Size each fixed column to the widest of its header and its cells so the + // table stays aligned; LOCATION is last and free-width. + let width = |header: &str, cell: fn(&ResolutionRow) -> usize| { + rows.iter() + .map(cell) + .chain(core::iter::once(header.len())) + .max() + .unwrap_or(0) + }; + let w_bin = width("BINARY", |row| row.binary.len()); + let w_ver = width("VERSION", |row| row.version.len()); + let w_status = width("STATUS", |row| row.status.len()); + let w_source = width("SOURCE", |row| row.source.len()); + + println!( + "Discovered UFFS binaries. STATUS: 'runs' = the copy a bare command executes \ + (first on PATH); 'shadowed' = on PATH but another runs first; 'off PATH' = \ + present but not on PATH.\n" + ); + // One printer for the header and every row, so the columns share widths and + // there are no bare format literals. + let print_row = |binary: &str, version: &str, status: &str, source: &str, location: &str| { + println!( + " {binary: Date: Tue, 30 Jun 2026 18:35:16 -0700 Subject: [PATCH 20/40] fix(uninstall): make deep-sweep drive coverage robust (kill+start, not a racing hot-load) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old coverage fired its own load_drive_letters for any not-yet-loaded drive, racing the daemon's background startup load over the single-instance Access Broker pipe (ERROR_PIPE_BUSY). That churned the registry (observed 2/6 -> 0/6), intermittently dropped a drive (6/7), and could spin to the wait cap. Replace it with the proven CLI flow, only when needed: - Managed set = every `status_drives` row (any tier — hot/warm/parked/cold; a search re-promotes a parked drive on demand). - If the daemon already covers every system drive: do nothing. - If ANY drive is missing: `uffs --daemon kill`, wait for full shutdown, then a clean `uffs --daemon start` (loads every drive with broker warm-up, returns only once Ready), then poll until coverage is complete. No `restart`, no hot-load, no competing loader thread. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 208 +++++++++++------- 1 file changed, 125 insertions(+), 83 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index a19521269..928286b78 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -3,121 +3,163 @@ //! Windows deep-sweep drive coverage for `uffs --uninstall`. //! -//! Before the live cross-drive search, make sure the daemon is running and -//! indexes every NTFS drive, **offering** to start it and index the missing -//! drives so the sweep is actually complete. Windows-only: off Windows UFFS -//! indexes offline MFT captures, not the live filesystem, so there is no live -//! drive coverage to ensure. +//! Before the live cross-drive search, make sure the daemon manages **every** +//! NTFS drive on the system. Windows-only: off Windows UFFS indexes offline MFT +//! captures, not the live filesystem, so there is no live drive coverage to +//! ensure. +//! +//! The robust part: if the daemon is already managing every drive (any tier — +//! hot / warm / parked / cold — a search re-promotes a parked drive on demand) +//! we do nothing. If even **one** drive is missing, we do NOT hot-load it — +//! that races the daemon's own background startup load over the single-instance +//! Access Broker pipe and churns the registry (observed `2/6 -> 0/6`). Instead +//! we replicate the proven CLI flow: `uffs --daemon kill` then a clean +//! `uffs --daemon start`, which loads every drive with proper broker warm-up +//! and only returns once the daemon reports Ready. //! //! Best-effort throughout: any RPC failure leaves coverage as-is and the sweep -//! proceeds against whatever is currently indexed. +//! proceeds against whatever is currently managed. #![cfg(windows)] +use core::time::Duration; +use std::path::Path; +use std::process::Command; +use std::time::Instant; + use uffs_client::connect_sync::UffsClientSync; use uffs_mft::platform::{DriveLetter, detect_ntfs_drives}; -/// How long to wait for newly-requested drives to finish loading before the -/// sweep runs. Generous because a cold multi-drive index is genuinely slow -/// (millions of records per volume); the previous 120 s cap expired mid-load on -/// a 7-drive system (~2.5 min), so the sweep searched a not-yet-ready index and -/// missed the still-loading drive. -const INDEX_WAIT: core::time::Duration = core::time::Duration::from_secs(600); +/// Max time to wait for the clean `--daemon start` to bring every system drive +/// under management before the sweep proceeds anyway (best-effort). Generous +/// because a cold multi-drive index is genuinely slow (millions of records per +/// volume). +const COVERAGE_WAIT: Duration = Duration::from_secs(600); + +/// Poll interval while waiting for the daemon to settle. +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// How long to wait for the daemon to fully exit after `--daemon kill` before +/// starting a fresh one (a lingering pipe would make `--daemon start` think a +/// daemon is still running and skip the start). +const SHUTDOWN_WAIT: Duration = Duration::from_secs(15); -/// Ensure the daemon covers every NTFS drive before the deep sweep: connect -/// (auto-starting the daemon if needed) and index any drives not yet loaded. +/// Ensure the daemon manages every NTFS drive before the deep sweep. /// -/// Indexing is a non-elevated, non-destructive read the sweep requires, so it -/// **always runs — no prompt**. Best-effort: a missing daemon or RPC failure -/// just means the sweep covers whatever is already indexed. +/// No-op when coverage is already complete. Otherwise runs the clean +/// kill-then-start flow. Best-effort: a missing daemon or RPC failure just +/// means the sweep covers whatever is already managed. pub(crate) fn ensure_drive_coverage() { let all = detect_ntfs_drives(); if all.is_empty() { return; } - // `connect()` auto-starts the daemon if it is not already running. - let Ok(mut client) = UffsClientSync::connect() else { - // Could not reach or start a daemon: nothing to cover, sweep as-is. - return; - }; - let indexed: Vec = client - .drives() - .map(|response| { - response - .drives - .into_iter() - .map(|drive| drive.letter) - .collect() - }) - .unwrap_or_default(); + let managed = current_managed_drives(); let missing: Vec = all - .into_iter() - .filter(|drive| !indexed.contains(drive)) + .iter() + .filter(|drive| !managed.contains(drive)) + .copied() .collect(); if missing.is_empty() { + // The daemon already covers every system drive — nothing to do. return; } - print_index_intro(missing.len()); - // Fire the (blocking) load on a *background* connection so this thread can - // poll `status_drives` for live progress while the daemon works through the - // drives. The load RPC can exceed the client timeout on a big multi-drive - // index — but the poll, not the RPC return, decides when the drives are - // searchable, so a background timeout is harmless. - let to_load = missing.clone(); - let loader = std::thread::spawn(move || { - if let Ok(mut background) = UffsClientSync::connect_raw() { - // Best-effort: the poll below is the source of truth for "ready". - let _outcome = background.load_drive_letters(&to_load, false); - } - }); - wait_until_loaded(&mut client, &missing); - let _joined = loader.join(); + clean_restart_for_coverage(&all, &missing); } -/// Intro line printed before the per-drive index progress. -#[expect(clippy::print_stdout, reason = "CLI progress output")] -fn print_index_intro(count: usize) { - println!("\nIndexing {count} drive(s) for the deep sweep (this can take a few minutes):"); +/// The set of drive letters the daemon currently manages (any tier). An empty +/// list means the daemon is not running or did not answer. +fn current_managed_drives() -> Vec { + UffsClientSync::connect_raw() + .map_or_else(|_| Vec::new(), |mut client| managed_letters(&mut client)) } -/// Poll interval while waiting for requested drives to finish loading. -const POLL_INTERVAL: core::time::Duration = core::time::Duration::from_millis(500); +/// Read the managed drive letters from `status_drives` (every row, regardless +/// of tier). Any RPC error yields an empty list (best-effort). +fn managed_letters(client: &mut UffsClientSync) -> Vec { + client.status_drives().map_or_else( + |_| Vec::new(), + |resp| resp.drives.into_iter().map(|row| row.letter).collect(), + ) +} -/// Poll `status_drives` until every drive in `wanted` reports a loaded -/// (searchable) shard — tier `hot` or `warm` — or [`INDEX_WAIT`] elapses. -/// -/// A freshly `load_drive_letters`-requested shard starts parked/cold and only -/// becomes searchable once its body is resident; searching before then is what -/// returned zero strays. Best-effort: any RPC error just keeps polling to the -/// deadline, after which the sweep proceeds with whatever is loaded. -fn wait_until_loaded(client: &mut UffsClientSync, wanted: &[DriveLetter]) { - let deadline = std::time::Instant::now() + INDEX_WAIT; - let mut last_ready = usize::MAX; +/// Replicate the robust CLI flow — `--daemon kill` then a clean `--daemon +/// start` — so the daemon reloads every system drive from scratch, then wait +/// for it to come back covering them all. +fn clean_restart_for_coverage(all: &[DriveLetter], missing: &[DriveLetter]) { + print_restart_intro(missing, all.len()); + let exe = uffs_client::daemon_ctl::find_uffs_exe(); + + // KILL: bring the partially-loaded daemon fully down first. + let _kill = run_uffs(&exe, &["--daemon", "kill"]); + wait_until_daemon_down(); + + // START: the clean startup path loads every detected drive (with broker + // warm-up) and only returns once the daemon reports Ready. + let _start = run_uffs(&exe, &["--daemon", "start"]); + + // Confirm the daemon came back covering every drive (progress feedback). + wait_until_covered(all); +} + +/// Run `uffs ` as a child, inheriting stdio so the daemon start output is +/// visible. Best-effort: a spawn failure is returned for the caller to ignore. +fn run_uffs(exe: &Path, args: &[&str]) -> std::io::Result { + Command::new(exe) + .args(args) + .stdin(std::process::Stdio::null()) + .status() +} + +/// Poll until the daemon is no longer reachable (fully shut down) or +/// [`SHUTDOWN_WAIT`] elapses. +fn wait_until_daemon_down() { + let deadline = Instant::now() + SHUTDOWN_WAIT; + while Instant::now() < deadline { + if UffsClientSync::connect_raw().is_err() { + return; + } + std::thread::sleep(POLL_INTERVAL); + } +} + +/// Poll `status_drives` until every drive in `all` is managed again, or +/// [`COVERAGE_WAIT`] elapses. Prints progress so a multi-minute reload never +/// looks like a hang. +fn wait_until_covered(all: &[DriveLetter]) { + let deadline = Instant::now() + COVERAGE_WAIT; + let mut last_covered = usize::MAX; loop { - let ready = client.status_drives().map_or(0, |resp| { - wanted - .iter() - .filter(|drive| { - resp.drives.iter().any(|row| { - row.letter == **drive && matches!(row.tier.as_str(), "hot" | "warm") - }) - }) - .count() - }); - // Progress feedback so a multi-minute index never looks like a hang. - if ready != last_ready { - print_index_progress(ready, wanted.len()); - last_ready = ready; + let managed = current_managed_drives(); + let covered = all.iter().filter(|drive| managed.contains(drive)).count(); + if covered != last_covered { + print_coverage_progress(covered, all.len()); + last_covered = covered; } - if ready == wanted.len() || std::time::Instant::now() >= deadline { + if covered == all.len() || Instant::now() >= deadline { return; } std::thread::sleep(POLL_INTERVAL); } } -/// Print drive-index progress for [`wait_until_loaded`]. +/// Announce the kill-then-start because coverage is incomplete. +#[expect(clippy::print_stdout, reason = "CLI progress output")] +fn print_restart_intro(missing: &[DriveLetter], total: usize) { + let list = missing + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + println!( + "\nDaemon is not indexing every drive (missing {list}; {covered} of {total} covered).\n\ + Restarting it cleanly (kill + start) for a complete deep sweep:", + covered = total.saturating_sub(missing.len()), + ); +} + +/// Print drive-coverage progress while the freshly started daemon reloads. #[expect(clippy::print_stdout, reason = "CLI progress output")] -fn print_index_progress(ready: usize, total: usize) { - println!(" indexing for the sweep: {ready}/{total} drives ready..."); +fn print_coverage_progress(covered: usize, total: usize) { + println!(" indexing for the sweep: {covered}/{total} drives ready..."); } From 238adf9de8c94df43e11893442d4ceb6ee15d54b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:22:13 -0700 Subject: [PATCH 21/40] fix(uninstall): never start the daemon in-process for coverage; degrade gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawning `uffs --daemon start` as a child of the uninstall intermittently hangs the drive load (daemon stuck at 5/7, zero progress) even though a standalone `uffs --daemon start` loads all drives in seconds. Rather than chase that spawn-context bug, stop bringing the daemon up in-process: - Fully covered already (warm daemon): proceed silently — the common case. - Daemon up but mid-load: wait briefly (60s cap), then proceed with whatever loaded. Never blocks indefinitely. - No daemon reachable: print a one-line notice telling the user to run `uffs --daemon start` and re-run for a complete sweep; continue best-effort. No kill, no start, no restart, no competing loader — the deep sweep now covers whatever the daemon already has and never hangs. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 181 ++++++++---------- 1 file changed, 77 insertions(+), 104 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 928286b78..3f477a822 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -3,72 +3,74 @@ //! Windows deep-sweep drive coverage for `uffs --uninstall`. //! -//! Before the live cross-drive search, make sure the daemon manages **every** -//! NTFS drive on the system. Windows-only: off Windows UFFS indexes offline MFT -//! captures, not the live filesystem, so there is no live drive coverage to -//! ensure. +//! The deep sweep searches the daemon's live index for stray family files. It +//! is only as complete as the set of drives the daemon has loaded, so before +//! the sweep we check coverage — but we deliberately do **not** start or +//! restart the daemon ourselves. //! -//! The robust part: if the daemon is already managing every drive (any tier — -//! hot / warm / parked / cold — a search re-promotes a parked drive on demand) -//! we do nothing. If even **one** drive is missing, we do NOT hot-load it — -//! that races the daemon's own background startup load over the single-instance -//! Access Broker pipe and churns the registry (observed `2/6 -> 0/6`). Instead -//! we replicate the proven CLI flow: `uffs --daemon kill` then a clean -//! `uffs --daemon start`, which loads every drive with proper broker warm-up -//! and only returns once the daemon reports Ready. +//! Why not: a standalone `uffs --daemon start` loads every NTFS drive in a few +//! seconds, but spawning that same command as a **child of the uninstall +//! process** intermittently hangs the drive load (two drives never finish — +//! observed a daemon stuck at `5/7` with zero progress across 15 s). Rather +//! than chase that spawn-context bug, we treat daemon startup as the user's +//! job: if the daemon already covers every drive we proceed silently; if a +//! daemon is up but still loading we wait briefly; otherwise we tell the user +//! to run `uffs --daemon start` and proceed best-effort with whatever is +//! loaded. //! -//! Best-effort throughout: any RPC failure leaves coverage as-is and the sweep -//! proceeds against whatever is currently managed. +//! Windows-only: off Windows UFFS indexes offline MFT captures, not the live +//! filesystem, so there is no live drive coverage to ensure. #![cfg(windows)] use core::time::Duration; -use std::path::Path; -use std::process::Command; use std::time::Instant; use uffs_client::connect_sync::UffsClientSync; use uffs_mft::platform::{DriveLetter, detect_ntfs_drives}; -/// Max time to wait for the clean `--daemon start` to bring every system drive -/// under management before the sweep proceeds anyway (best-effort). Generous -/// because a cold multi-drive index is genuinely slow (millions of records per -/// volume). -const COVERAGE_WAIT: Duration = Duration::from_secs(600); +/// Short wait for a daemon that is up but still loading drives to catch up +/// before the sweep proceeds. Bounded on purpose — we never block the uninstall +/// on a slow or stuck load; we proceed best-effort when it elapses. +const BRIEF_WAIT: Duration = Duration::from_secs(60); -/// Poll interval while waiting for the daemon to settle. +/// Poll interval while waiting for a mid-load daemon to settle. const POLL_INTERVAL: Duration = Duration::from_millis(500); -/// How long to wait for the daemon to fully exit after `--daemon kill` before -/// starting a fresh one (a lingering pipe would make `--daemon start` think a -/// daemon is still running and skip the start). -const SHUTDOWN_WAIT: Duration = Duration::from_secs(15); - -/// Ensure the daemon manages every NTFS drive before the deep sweep. -/// -/// No-op when coverage is already complete. Otherwise runs the clean -/// kill-then-start flow. Best-effort: a missing daemon or RPC failure just -/// means the sweep covers whatever is already managed. +/// Ensure — best-effort — that the daemon covers every NTFS drive before the +/// deep sweep, without ever starting the daemon in-process (see module docs). pub(crate) fn ensure_drive_coverage() { let all = detect_ntfs_drives(); if all.is_empty() { return; } - let managed = current_managed_drives(); - let missing: Vec = all - .iter() - .filter(|drive| !managed.contains(drive)) - .copied() - .collect(); - if missing.is_empty() { - // The daemon already covers every system drive — nothing to do. + // Common case: a warm daemon already covers every drive — proceed silently. + if covered_count(&all) == all.len() { + return; + } + // Coverage is incomplete. If no daemon is reachable, there is nothing to + // wait for — tell the user and continue with a limited sweep. + if UffsClientSync::connect_raw().is_err() { + print_no_daemon_notice(); return; } - clean_restart_for_coverage(&all, &missing); + // A daemon is up but not yet covering every drive — it may just be + // mid-load. Wait briefly for it to catch up, then proceed with whatever is + // loaded. + let covered = wait_briefly_for_coverage(&all); + if covered < all.len() { + print_partial_coverage_notice(covered, all.len()); + } +} + +/// How many of `all` the daemon currently manages (any tier). +fn covered_count(all: &[DriveLetter]) -> usize { + let managed = current_managed_drives(); + all.iter().filter(|drive| managed.contains(drive)).count() } -/// The set of drive letters the daemon currently manages (any tier). An empty -/// list means the daemon is not running or did not answer. +/// The drive letters the daemon currently manages (any tier). Empty when the +/// daemon is not running or did not answer. fn current_managed_drives() -> Vec { UffsClientSync::connect_raw() .map_or_else(|_| Vec::new(), |mut client| managed_letters(&mut client)) @@ -83,83 +85,54 @@ fn managed_letters(client: &mut UffsClientSync) -> Vec { ) } -/// Replicate the robust CLI flow — `--daemon kill` then a clean `--daemon -/// start` — so the daemon reloads every system drive from scratch, then wait -/// for it to come back covering them all. -fn clean_restart_for_coverage(all: &[DriveLetter], missing: &[DriveLetter]) { - print_restart_intro(missing, all.len()); - let exe = uffs_client::daemon_ctl::find_uffs_exe(); - - // KILL: bring the partially-loaded daemon fully down first. - let _kill = run_uffs(&exe, &["--daemon", "kill"]); - wait_until_daemon_down(); - - // START: the clean startup path loads every detected drive (with broker - // warm-up) and only returns once the daemon reports Ready. - let _start = run_uffs(&exe, &["--daemon", "start"]); - - // Confirm the daemon came back covering every drive (progress feedback). - wait_until_covered(all); -} - -/// Run `uffs ` as a child, inheriting stdio so the daemon start output is -/// visible. Best-effort: a spawn failure is returned for the caller to ignore. -fn run_uffs(exe: &Path, args: &[&str]) -> std::io::Result { - Command::new(exe) - .args(args) - .stdin(std::process::Stdio::null()) - .status() -} - -/// Poll until the daemon is no longer reachable (fully shut down) or -/// [`SHUTDOWN_WAIT`] elapses. -fn wait_until_daemon_down() { - let deadline = Instant::now() + SHUTDOWN_WAIT; - while Instant::now() < deadline { - if UffsClientSync::connect_raw().is_err() { - return; - } - std::thread::sleep(POLL_INTERVAL); - } -} - -/// Poll `status_drives` until every drive in `all` is managed again, or -/// [`COVERAGE_WAIT`] elapses. Prints progress so a multi-minute reload never -/// looks like a hang. -fn wait_until_covered(all: &[DriveLetter]) { - let deadline = Instant::now() + COVERAGE_WAIT; +/// Poll until the daemon covers every drive in `all`, or [`BRIEF_WAIT`] +/// elapses. Prints progress so a mid-load wait never looks like a hang. Returns +/// the final covered count. +fn wait_briefly_for_coverage(all: &[DriveLetter]) -> usize { + print_wait_intro(all.len()); + let deadline = Instant::now() + BRIEF_WAIT; let mut last_covered = usize::MAX; loop { - let managed = current_managed_drives(); - let covered = all.iter().filter(|drive| managed.contains(drive)).count(); + let covered = covered_count(all); if covered != last_covered { print_coverage_progress(covered, all.len()); last_covered = covered; } if covered == all.len() || Instant::now() >= deadline { - return; + return covered; } std::thread::sleep(POLL_INTERVAL); } } -/// Announce the kill-then-start because coverage is incomplete. +/// Announce a brief wait for a mid-load daemon. #[expect(clippy::print_stdout, reason = "CLI progress output")] -fn print_restart_intro(missing: &[DriveLetter], total: usize) { - let list = missing - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - println!( - "\nDaemon is not indexing every drive (missing {list}; {covered} of {total} covered).\n\ - Restarting it cleanly (kill + start) for a complete deep sweep:", - covered = total.saturating_sub(missing.len()), - ); +fn print_wait_intro(total: usize) { + println!("\nWaiting for the daemon to finish loading all {total} drive(s) for the deep sweep:"); } -/// Print drive-coverage progress while the freshly started daemon reloads. +/// Print drive-coverage progress while the daemon finishes loading. #[expect(clippy::print_stdout, reason = "CLI progress output")] fn print_coverage_progress(covered: usize, total: usize) { println!(" indexing for the sweep: {covered}/{total} drives ready..."); } + +/// No daemon is running: the sweep cannot scan the live index. Tell the user +/// how to enable a complete sweep; the uninstall continues regardless. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_no_daemon_notice() { + println!( + "\nNo index daemon is running, so the deep sweep can only cover what is already loaded.\n\ + For a complete sweep, run `uffs --daemon start` and re-run the uninstall. Continuing..." + ); +} + +/// The daemon covers only some drives after the brief wait. Note it and how to +/// get full coverage; the uninstall continues with a partial sweep. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_partial_coverage_notice(covered: usize, total: usize) { + println!( + "\nDaemon covers {covered} of {total} drive(s); the deep sweep will scan those.\n\ + For full coverage, run `uffs --daemon start` and re-run the uninstall. Continuing..." + ); +} From 889b0cd3add808e108a5d796be900d3c6cc42171 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:42:43 -0700 Subject: [PATCH 22/40] fix(uninstall): reload daemon for coverage via the real CLI handlers (kill+start in-process) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt shelled out to `uffs.exe --daemon start` as a subprocess, which spawned the daemon as a grandchild and intermittently hung its drive load (stuck at 5/7). A standalone `uffs --daemon start` loads all drives in seconds. Reuse the exact handlers the CLI dispatches instead: call `daemon_mgmt::daemon(&DaemonAction::Kill)` then `daemon(&Start{..})` in-process, so the daemon is a DIRECT child of this process — identical topology to a shell `uffs --daemon start`. When coverage is complete, no-op silently; when a drive is missing, kill, wait for full shutdown, start (blocks until Ready = all drives loaded), then proceed. Any handler error is best-effort: note it and sweep with whatever is loaded. Co-Authored-By: Claude Opus 4.8 --- .../src/commands/uninstall/coverage.rs | 176 ++++++++++-------- 1 file changed, 98 insertions(+), 78 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 3f477a822..5d4e28c17 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -3,20 +3,19 @@ //! Windows deep-sweep drive coverage for `uffs --uninstall`. //! -//! The deep sweep searches the daemon's live index for stray family files. It -//! is only as complete as the set of drives the daemon has loaded, so before -//! the sweep we check coverage — but we deliberately do **not** start or -//! restart the daemon ourselves. +//! The deep sweep searches the daemon's live index for stray family files, so +//! it is only as complete as the set of drives the daemon has loaded. Before +//! the sweep we make sure the daemon covers every NTFS drive; if it does not, +//! we reload it cleanly — **kill then start** — by calling the exact same +//! handlers the CLI dispatches for `uffs --daemon kill` / `uffs --daemon start` +//! ([`daemon_mgmt::daemon`]), in-process. //! -//! Why not: a standalone `uffs --daemon start` loads every NTFS drive in a few -//! seconds, but spawning that same command as a **child of the uninstall -//! process** intermittently hangs the drive load (two drives never finish — -//! observed a daemon stuck at `5/7` with zero progress across 15 s). Rather -//! than chase that spawn-context bug, we treat daemon startup as the user's -//! job: if the daemon already covers every drive we proceed silently; if a -//! daemon is up but still loading we wait briefly; otherwise we tell the user -//! to run `uffs --daemon start` and proceed best-effort with whatever is -//! loaded. +//! Calling those handlers directly is the whole point: the daemon is then +//! spawned as a **direct child of this process**, identical to a shell +//! `uffs --daemon start`. An earlier attempt shelled out to +//! `uffs.exe --daemon start` as a subprocess, which made the daemon a +//! *grandchild* and intermittently hung its drive load (stuck at `5/7`). +//! Re-using the handler avoids that entirely. //! //! Windows-only: off Windows UFFS indexes offline MFT captures, not the live //! filesystem, so there is no live drive coverage to ensure. @@ -29,44 +28,37 @@ use std::time::Instant; use uffs_client::connect_sync::UffsClientSync; use uffs_mft::platform::{DriveLetter, detect_ntfs_drives}; -/// Short wait for a daemon that is up but still loading drives to catch up -/// before the sweep proceeds. Bounded on purpose — we never block the uninstall -/// on a slow or stuck load; we proceed best-effort when it elapses. -const BRIEF_WAIT: Duration = Duration::from_secs(60); +use crate::args::DaemonAction; +use crate::commands::daemon_mgmt; -/// Poll interval while waiting for a mid-load daemon to settle. +/// How long to wait for the daemon to fully exit after `kill` before starting a +/// fresh one (a lingering pipe would make `start` see "already running" and +/// skip the reload). +const SHUTDOWN_WAIT: Duration = Duration::from_secs(15); + +/// Poll interval while waiting for shutdown. const POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Ensure — best-effort — that the daemon covers every NTFS drive before the -/// deep sweep, without ever starting the daemon in-process (see module docs). +/// Ensure the daemon covers every NTFS drive before the deep sweep. No-op when +/// coverage is already complete; otherwise reload the daemon (kill + start) +/// via the real CLI handlers. Best-effort: any failure just means the sweep +/// covers whatever is currently loaded. pub(crate) fn ensure_drive_coverage() { let all = detect_ntfs_drives(); if all.is_empty() { return; } - // Common case: a warm daemon already covers every drive — proceed silently. - if covered_count(&all) == all.len() { - return; - } - // Coverage is incomplete. If no daemon is reachable, there is nothing to - // wait for — tell the user and continue with a limited sweep. - if UffsClientSync::connect_raw().is_err() { - print_no_daemon_notice(); + let managed = current_managed_drives(); + let missing: Vec = all + .iter() + .filter(|drive| !managed.contains(drive)) + .copied() + .collect(); + if missing.is_empty() { + // The daemon already covers every system drive — proceed silently. return; } - // A daemon is up but not yet covering every drive — it may just be - // mid-load. Wait briefly for it to catch up, then proceed with whatever is - // loaded. - let covered = wait_briefly_for_coverage(&all); - if covered < all.len() { - print_partial_coverage_notice(covered, all.len()); - } -} - -/// How many of `all` the daemon currently manages (any tier). -fn covered_count(all: &[DriveLetter]) -> usize { - let managed = current_managed_drives(); - all.iter().filter(|drive| managed.contains(drive)).count() + reload_daemon_for_coverage(&all, &missing); } /// The drive letters the daemon currently manages (any tier). Empty when the @@ -85,54 +77,82 @@ fn managed_letters(client: &mut UffsClientSync) -> Vec { ) } -/// Poll until the daemon covers every drive in `all`, or [`BRIEF_WAIT`] -/// elapses. Prints progress so a mid-load wait never looks like a hang. Returns -/// the final covered count. -fn wait_briefly_for_coverage(all: &[DriveLetter]) -> usize { - print_wait_intro(all.len()); - let deadline = Instant::now() + BRIEF_WAIT; - let mut last_covered = usize::MAX; - loop { - let covered = covered_count(all); - if covered != last_covered { - print_coverage_progress(covered, all.len()); - last_covered = covered; - } - if covered == all.len() || Instant::now() >= deadline { - return covered; - } - std::thread::sleep(POLL_INTERVAL); +/// Reload the daemon so it covers every drive: `kill`, wait for it to exit, +/// then `start`. Both steps go through [`daemon_mgmt::daemon`] — the exact +/// handlers `uffs --daemon kill` / `uffs --daemon start` use — so the daemon is +/// spawned in-process as a direct child (see module docs). +fn reload_daemon_for_coverage(all: &[DriveLetter], missing: &[DriveLetter]) { + print_reload_intro(missing, all.len()); + + if let Err(err) = daemon_mgmt::daemon(&DaemonAction::Kill) { + print_reload_failed("kill the daemon", &err); + return; + } + wait_until_daemon_down(); + + // `daemon start` blocks until the daemon is Ready (every drive loaded), so + // on success coverage is complete. + if let Err(err) = daemon_mgmt::daemon(&start_action()) { + print_reload_failed("start the daemon", &err); + return; + } + + let managed = current_managed_drives(); + let covered = all.iter().filter(|drive| managed.contains(drive)).count(); + if covered < all.len() { + print_partial_coverage_notice(covered, all.len()); } } -/// Announce a brief wait for a mid-load daemon. -#[expect(clippy::print_stdout, reason = "CLI progress output")] -fn print_wait_intro(total: usize) { - println!("\nWaiting for the daemon to finish loading all {total} drive(s) for the deep sweep:"); +/// The [`DaemonAction::Start`] a bare `uffs --daemon start` produces: auto- +/// discover every NTFS drive, use the cache, default logging, no UAC prompt. +fn start_action() -> DaemonAction { + DaemonAction::Start { + mft_file: Vec::new(), + data_dir: None, + drives: Vec::new(), + no_cache: false, + log_level: "info".to_owned(), + log_file: None, + elevate: false, + } } -/// Print drive-coverage progress while the daemon finishes loading. -#[expect(clippy::print_stdout, reason = "CLI progress output")] -fn print_coverage_progress(covered: usize, total: usize) { - println!(" indexing for the sweep: {covered}/{total} drives ready..."); +/// Poll until the daemon is no longer reachable (fully shut down) or +/// [`SHUTDOWN_WAIT`] elapses. +fn wait_until_daemon_down() { + let deadline = Instant::now() + SHUTDOWN_WAIT; + while Instant::now() < deadline { + if UffsClientSync::connect_raw().is_err() { + return; + } + std::thread::sleep(POLL_INTERVAL); + } } -/// No daemon is running: the sweep cannot scan the live index. Tell the user -/// how to enable a complete sweep; the uninstall continues regardless. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn print_no_daemon_notice() { +/// Announce the kill+start because coverage is incomplete. +#[expect(clippy::print_stdout, reason = "CLI progress output")] +fn print_reload_intro(missing: &[DriveLetter], total: usize) { + let list = missing + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); println!( - "\nNo index daemon is running, so the deep sweep can only cover what is already loaded.\n\ - For a complete sweep, run `uffs --daemon start` and re-run the uninstall. Continuing..." + "\nDaemon is not indexing every drive (missing {list}; {covered} of {total} covered).\n\ + Reloading it (kill + start) for a complete deep sweep:", + covered = total.saturating_sub(missing.len()), ); } -/// The daemon covers only some drives after the brief wait. Note it and how to -/// get full coverage; the uninstall continues with a partial sweep. +/// Note that the reload could not complete; the sweep continues best-effort. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_reload_failed(what: &str, err: &anyhow::Error) { + println!(" could not {what}: {err}. Continuing the deep sweep with whatever is loaded."); +} + +/// Note that the daemon covers only some drives after the reload. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] fn print_partial_coverage_notice(covered: usize, total: usize) { - println!( - "\nDaemon covers {covered} of {total} drive(s); the deep sweep will scan those.\n\ - For full coverage, run `uffs --daemon start` and re-run the uninstall. Continuing..." - ); + println!(" daemon covers {covered} of {total} drive(s); the deep sweep will scan those."); } From a80496abcdfa253b2ca5ee0cd49323aeacc551f9 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:59:53 -0700 Subject: [PATCH 23/40] build(windows): embed icon + version-info + manifest into the 4 bare binaries Only uffs.exe and uffs-update.exe carried Windows PE resources; uffsd, uffsmcp, uffs-broker, and uffs-mft shipped bare. A metadata-less unsigned binary is both unbranded and a mild antivirus ML false-positive signal (all 7 tripped the same generic Defender heuristic on the 0.6.18 release). Add a per-crate build.rs to each (mirroring uffs-cli/uffs-update) that embeds via winresource on MSVC-Windows only: - the UFFS icon (shared assets/brand/icons/uffs.ico) - version info: ProductName, FileDescription, CompanyName, LegalCopyright, OriginalFilename (winresource auto-fills File/ProductVersion from the crate) - a new shared assets/brand/app.manifest (asInvoker, PerMonitorV2, longPathAware) winresource added as a build-dependency of each crate. No-op off Windows; the uffs-mft library target is unaffected. Validated with cargo xwin clippy for x86_64-pc-windows-msvc. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 4 +++ assets/brand/app.manifest | 41 +++++++++++++++++++++++++++++++ crates/uffs-broker/Cargo.toml | 6 +++++ crates/uffs-broker/build.rs | 46 +++++++++++++++++++++++++++++++++++ crates/uffs-daemon/Cargo.toml | 6 +++++ crates/uffs-daemon/build.rs | 45 +++++++++++++++++++++++++++++----- crates/uffs-mcp/Cargo.toml | 6 +++++ crates/uffs-mcp/build.rs | 43 ++++++++++++++++++++++++++++++++ crates/uffs-mft/Cargo.toml | 6 +++++ crates/uffs-mft/build.rs | 44 +++++++++++++++++++++++++++++++++ 10 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 assets/brand/app.manifest create mode 100644 crates/uffs-broker/build.rs create mode 100644 crates/uffs-mcp/build.rs create mode 100644 crates/uffs-mft/build.rs diff --git a/Cargo.lock b/Cargo.lock index 70b804226..019647ee7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4397,6 +4397,7 @@ dependencies = [ "uffs-security", "uffs-winsvc", "windows 0.62.2", + "winresource", ] [[package]] @@ -4522,6 +4523,7 @@ dependencies = [ "uffs-mft", "uffs-security", "windows 0.62.2", + "winresource", ] [[package]] @@ -4599,6 +4601,7 @@ dependencies = [ "uffs-client", "uffs-mft", "uffs-security", + "winresource", ] [[package]] @@ -4636,6 +4639,7 @@ dependencies = [ "uffs-security", "uffs-text", "windows 0.62.2", + "winresource", "zerocopy", "zstd", ] diff --git a/assets/brand/app.manifest b/assets/brand/app.manifest new file mode 100644 index 000000000..843eb6ae9 --- /dev/null +++ b/assets/brand/app.manifest @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + PerMonitorV2 + true + + + + + + + + + + diff --git a/crates/uffs-broker/Cargo.toml b/crates/uffs-broker/Cargo.toml index 68cf9d031..9dc3be5a3 100644 --- a/crates/uffs-broker/Cargo.toml +++ b/crates/uffs-broker/Cargo.toml @@ -70,5 +70,11 @@ uffs-security.workspace = true # `--stop` commands — the same locale-proof primitive the updater uses. uffs-winsvc.workspace = true +# Embeds the UFFS icon + version info + shared app.manifest into +# `uffs-broker.exe` (see build.rs). A metadata-less binary is both unbranded +# and a mild antivirus false-positive signal. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/crates/uffs-broker/build.rs b/crates/uffs-broker/build.rs new file mode 100644 index 000000000..27ee9fdc1 --- /dev/null +++ b/crates/uffs-broker/build.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-broker`. +//! +//! Embeds Windows PE resources — the UFFS icon, version info (company, product, +//! description), and the shared `app.manifest` — into `uffs-broker.exe` via +//! [`winresource`](https://crates.io/crates/winresource), so the shipped binary +//! carries proper metadata instead of shipping bare. A bare binary is both +//! unbranded and a mild antivirus false-positive signal. MSVC-Windows only; a +//! no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set( + "FileDescription", + "UFFS Access Broker (elevated MFT handle service)", + ) + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set("OriginalFilename", "uffs-broker.exe") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-broker resources"); +} diff --git a/crates/uffs-daemon/Cargo.toml b/crates/uffs-daemon/Cargo.toml index 9033b50ab..73ff51e0d 100644 --- a/crates/uffs-daemon/Cargo.toml +++ b/crates/uffs-daemon/Cargo.toml @@ -123,5 +123,11 @@ tempfile.workspace = true # the pattern already used by `uffs-core`, `uffs-mft`, and `uffs-mcp`. tokio = { workspace = true, features = ["test-util", "macros"] } +# Embeds the UFFS icon + version info + shared app.manifest into `uffsd.exe` +# (see build.rs, alongside the UFFS_GIT_SHA stamp). A metadata-less binary is +# both unbranded and a mild antivirus false-positive signal. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/crates/uffs-daemon/build.rs b/crates/uffs-daemon/build.rs index e21c8d5c5..5e0f4cf0f 100644 --- a/crates/uffs-daemon/build.rs +++ b/crates/uffs-daemon/build.rs @@ -12,16 +12,25 @@ //! Build script for `uffs-daemon`. //! -//! Emits `UFFS_GIT_SHA` — the short commit the daemon was built from, with a -//! `-dirty` suffix when the working tree had uncommitted changes — so the -//! startup log can stamp **which build** is running. A definitive build stamp -//! in the daemon log is how a field log (or a WIN test-script) is tied back to -//! the exact binary that produced it, closing the "ran the wrong/stale binary" -//! trap. Read back via `option_env!("UFFS_GIT_SHA")` in `startup.rs`. +//! Two jobs: +//! +//! 1. Emits `UFFS_GIT_SHA` — the short commit the daemon was built from, with a +//! `-dirty` suffix when the working tree had uncommitted changes — so the +//! startup log can stamp **which build** is running. A definitive build +//! stamp in the daemon log is how a field log (or a WIN test-script) is tied +//! back to the exact binary that produced it, closing the "ran the +//! wrong/stale binary" trap. Read back via `option_env!("UFFS_GIT_SHA")` in +//! `startup.rs`. +//! 2. On MSVC-Windows, embeds PE resources (UFFS icon, version info, shared +//! `app.manifest`) into `uffsd.exe` via [`winresource`], so the shipped +//! binary carries proper metadata instead of shipping bare — a bare binary +//! is both unbranded and a mild antivirus false-positive signal. use std::process::Command; fn main() { + embed_windows_resources(); + let sha = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() @@ -51,3 +60,27 @@ fn main() { println!("cargo:rerun-if-changed=../../.git/HEAD"); println!("cargo:rerun-if-changed=build.rs"); } + +/// Embed the UFFS icon, version info, and shared `app.manifest` into +/// `uffsd.exe` on MSVC-Windows; a no-op on every other build target. +fn embed_windows_resources() { + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS daemon (resident index server)") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set("OriginalFilename", "uffsd.exe") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-daemon resources"); +} diff --git a/crates/uffs-mcp/Cargo.toml b/crates/uffs-mcp/Cargo.toml index 679abf94d..b0ea7d778 100644 --- a/crates/uffs-mcp/Cargo.toml +++ b/crates/uffs-mcp/Cargo.toml @@ -135,5 +135,11 @@ clap = { workspace = true, features = ["derive"] } rmcp = { workspace = true, features = ["client"] } tokio = { workspace = true, features = ["test-util"] } +# Embeds the UFFS icon + version info + shared app.manifest into `uffsmcp.exe` +# (see build.rs). A metadata-less binary is both unbranded and a mild antivirus +# false-positive signal. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/crates/uffs-mcp/build.rs b/crates/uffs-mcp/build.rs new file mode 100644 index 000000000..f7671a434 --- /dev/null +++ b/crates/uffs-mcp/build.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-mcp`. +//! +//! Embeds Windows PE resources — the UFFS icon, version info (company, product, +//! description), and the shared `app.manifest` — into `uffsmcp.exe` via +//! [`winresource`](https://crates.io/crates/winresource), so the shipped binary +//! carries proper metadata instead of shipping bare. A bare binary is both +//! unbranded and a mild antivirus false-positive signal. MSVC-Windows only; a +//! no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS MCP server (AI agent tool gateway)") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set("OriginalFilename", "uffsmcp.exe") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-mcp resources"); +} diff --git a/crates/uffs-mft/Cargo.toml b/crates/uffs-mft/Cargo.toml index e48113416..52e7dcd2f 100644 --- a/crates/uffs-mft/Cargo.toml +++ b/crates/uffs-mft/Cargo.toml @@ -131,5 +131,11 @@ tempfile.workspace = true name = "mft_read" harness = false +# Embeds the UFFS icon + version info + shared app.manifest into `uffs-mft.exe` +# (see build.rs). A metadata-less binary is both unbranded and a mild antivirus +# false-positive signal. Only affects the bin target; the library is unchanged. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/crates/uffs-mft/build.rs b/crates/uffs-mft/build.rs new file mode 100644 index 000000000..4e55b164a --- /dev/null +++ b/crates/uffs-mft/build.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-mft`. +//! +//! Embeds Windows PE resources — the UFFS icon, version info (company, product, +//! description), and the shared `app.manifest` — into `uffs-mft.exe` via +//! [`winresource`](https://crates.io/crates/winresource), so the shipped binary +//! carries proper metadata instead of shipping bare. A bare binary is both +//! unbranded and a mild antivirus false-positive signal. MSVC-Windows only; a +//! no-op on every other build target (the crate's library targets are +//! unaffected either way). + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS MFT reader and diagnostics tool") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set("OriginalFilename", "uffs-mft.exe") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-mft resources"); +} From 6276765c97f6c5c435c45250b0ef0f0ee74f883c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:10:25 -0700 Subject: [PATCH 24/40] build(windows): embed UFFS icon + metadata into the 14 dev/CI binaries Branding consistency across the whole family: the diagnostic + CI tools shipped bare (no icon/version info). Add a per-crate build.rs embedding the shared UFFS icon + version info + app.manifest via winresource (MSVC-Windows only, no-op elsewhere) to the 6 crates that produce them: - uffs-diag (9 bins: analyze-diff, analyze-mft-parents, compare-raw-mft, compare-scan-parity, cross-check-mft-reference, dump-mft-extents, dump-mft-records, inspect-mft-record-flow, scan-mft-magic) - uffs-bench (uffs-bench) - scripts/ci/gen-hooks, scripts/ci/gen-workflow, scripts/ci/manifest-audit - scripts/ci-pipeline (uffs-ci-pipeline) winresource added as a build-dependency of each. Validated with cargo xwin clippy for x86_64-pc-windows-msvc. Completes the all-binaries icon-branding task. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 6 ++++ crates/uffs-bench/Cargo.toml | 5 ++++ crates/uffs-bench/build.rs | 40 +++++++++++++++++++++++++++ crates/uffs-diag/Cargo.toml | 5 ++++ crates/uffs-diag/build.rs | 41 ++++++++++++++++++++++++++++ scripts/ci-pipeline/Cargo.toml | 5 ++++ scripts/ci-pipeline/build.rs | 40 +++++++++++++++++++++++++++ scripts/ci/gen-hooks/Cargo.toml | 5 ++++ scripts/ci/gen-hooks/build.rs | 40 +++++++++++++++++++++++++++ scripts/ci/gen-workflow/Cargo.toml | 5 ++++ scripts/ci/gen-workflow/build.rs | 40 +++++++++++++++++++++++++++ scripts/ci/manifest-audit/Cargo.toml | 5 ++++ scripts/ci/manifest-audit/build.rs | 40 +++++++++++++++++++++++++++ 13 files changed, 277 insertions(+) create mode 100644 crates/uffs-bench/build.rs create mode 100644 crates/uffs-diag/build.rs create mode 100644 scripts/ci-pipeline/build.rs create mode 100644 scripts/ci/gen-hooks/build.rs create mode 100644 scripts/ci/gen-workflow/build.rs create mode 100644 scripts/ci/manifest-audit/build.rs diff --git a/Cargo.lock b/Cargo.lock index 019647ee7..b8016b4cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4384,6 +4384,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "winresource", ] [[package]] @@ -4422,6 +4423,7 @@ dependencies = [ "serde_json", "tokio", "uuid", + "winresource", ] [[package]] @@ -4537,6 +4539,7 @@ dependencies = [ "sha2 0.11.0", "uffs-mft", "uffs-polars", + "winresource", ] [[package]] @@ -4558,6 +4561,7 @@ dependencies = [ "clap", "serde", "toml", + "winresource", ] [[package]] @@ -4569,6 +4573,7 @@ dependencies = [ "regex", "serde", "toml", + "winresource", ] [[package]] @@ -4579,6 +4584,7 @@ dependencies = [ "clap", "serde", "toml", + "winresource", ] [[package]] diff --git a/crates/uffs-bench/Cargo.toml b/crates/uffs-bench/Cargo.toml index d835ea225..9eab5bc86 100644 --- a/crates/uffs-bench/Cargo.toml +++ b/crates/uffs-bench/Cargo.toml @@ -74,5 +74,10 @@ tempfile.workspace = true # ───────────────────────────────────────────────────────────────────────────── # Lints (inherit from workspace) # ───────────────────────────────────────────────────────────────────────────── +# Embeds the UFFS icon + version info + shared app.manifest into `uffs-bench.exe` +# (see build.rs) for branding consistency. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/crates/uffs-bench/build.rs b/crates/uffs-bench/build.rs new file mode 100644 index 000000000..a390b1551 --- /dev/null +++ b/crates/uffs-bench/build.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-bench`. +//! +//! Embeds the UFFS icon + version info + shared `app.manifest` into +//! `uffs-bench.exe` via [`winresource`](https://crates.io/crates/winresource), +//! for branding consistency with the rest of the UFFS binary family. +//! MSVC-Windows only; a no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS benchmark suite") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-bench resources"); +} diff --git a/crates/uffs-diag/Cargo.toml b/crates/uffs-diag/Cargo.toml index 338028090..4d655cc14 100644 --- a/crates/uffs-diag/Cargo.toml +++ b/crates/uffs-diag/Cargo.toml @@ -144,5 +144,10 @@ uffs-polars.workspace = true # ───────────────────────────────────────────────────────────────────────────── # Lints (inherit from workspace) # ───────────────────────────────────────────────────────────────────────────── +# Embeds the UFFS icon + version info + shared app.manifest into the diagnostic +# binaries (see build.rs) for branding consistency. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/crates/uffs-diag/build.rs b/crates/uffs-diag/build.rs new file mode 100644 index 000000000..5f47ebc04 --- /dev/null +++ b/crates/uffs-diag/build.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-diag`. +//! +//! Embeds the UFFS icon + version info + shared `app.manifest` into the crate's +//! diagnostic binaries via [`winresource`](https://crates.io/crates/winresource), +//! for branding consistency with the rest of the UFFS binary family (one .res +//! is linked into every `[[bin]]`). MSVC-Windows only; a no-op on every other +//! build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS MFT diagnostic tools") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-diag resources"); +} diff --git a/scripts/ci-pipeline/Cargo.toml b/scripts/ci-pipeline/Cargo.toml index d88e11b2a..250e29328 100644 --- a/scripts/ci-pipeline/Cargo.toml +++ b/scripts/ci-pipeline/Cargo.toml @@ -85,5 +85,10 @@ tokio = { workspace = true, features = ["process"] } # uffs-diag). CLI-inappropriate lints are suppressed at file scope via # `#![expect(...)]` blocks in `src/*.rs`; see the header comment block # above for the rationale. +# Embeds the UFFS icon + version info + shared app.manifest into +# `uffs-ci-pipeline.exe` (see build.rs) for branding consistency. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/scripts/ci-pipeline/build.rs b/scripts/ci-pipeline/build.rs new file mode 100644 index 000000000..416a8b67d --- /dev/null +++ b/scripts/ci-pipeline/build.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `ci-pipeline` (`uffs-ci-pipeline`). +//! +//! Embeds the UFFS icon + version info + shared `app.manifest` into +//! `uffs-ci-pipeline.exe` via [`winresource`](https://crates.io/crates/winresource), +//! for branding consistency with the rest of the UFFS binary family. +//! MSVC-Windows only; a no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS CI pipeline runner") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed ci-pipeline resources"); +} diff --git a/scripts/ci/gen-hooks/Cargo.toml b/scripts/ci/gen-hooks/Cargo.toml index 8b1ff7888..58f83442d 100644 --- a/scripts/ci/gen-hooks/Cargo.toml +++ b/scripts/ci/gen-hooks/Cargo.toml @@ -72,5 +72,10 @@ toml = { workspace = true } # stderr-printing sites carry function-scoped # `#[expect(clippy::print_stderr, reason = "…")]` rather than disabling # the lint workspace-wide. See `CLIPPY_POSTURE.md` § CLI-tooling. +# Embeds the UFFS icon + version info + shared app.manifest into `gen-hooks.exe` +# (see build.rs) for branding consistency. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/scripts/ci/gen-hooks/build.rs b/scripts/ci/gen-hooks/build.rs new file mode 100644 index 000000000..b20916838 --- /dev/null +++ b/scripts/ci/gen-hooks/build.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `gen-hooks`. +//! +//! Embeds the UFFS icon + version info + shared `app.manifest` into +//! `gen-hooks.exe` via [`winresource`](https://crates.io/crates/winresource), +//! for branding consistency with the rest of the UFFS binary family. +//! MSVC-Windows only; a no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS CI: git hooks generator") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set_manifest_file("../../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed gen-hooks resources"); +} diff --git a/scripts/ci/gen-workflow/Cargo.toml b/scripts/ci/gen-workflow/Cargo.toml index 92eab1ae8..e36e66269 100644 --- a/scripts/ci/gen-workflow/Cargo.toml +++ b/scripts/ci/gen-workflow/Cargo.toml @@ -64,5 +64,10 @@ toml = { workspace = true } # blocks in `src/*.rs` with `reason = "..."` strings, matching the # precedent in `crates/uffs-diag/src/bin/*.rs`. See `CLIPPY_POSTURE.md` # § CLI-tooling for the policy. +# Embeds the UFFS icon + version info + shared app.manifest into +# `gen-workflow.exe` (see build.rs) for branding consistency. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/scripts/ci/gen-workflow/build.rs b/scripts/ci/gen-workflow/build.rs new file mode 100644 index 000000000..e2c0e08c3 --- /dev/null +++ b/scripts/ci/gen-workflow/build.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `gen-workflow`. +//! +//! Embeds the UFFS icon + version info + shared `app.manifest` into +//! `gen-workflow.exe` via [`winresource`](https://crates.io/crates/winresource), +//! for branding consistency with the rest of the UFFS binary family. +//! MSVC-Windows only; a no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS CI: workflow generator") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set_manifest_file("../../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed gen-workflow resources"); +} diff --git a/scripts/ci/manifest-audit/Cargo.toml b/scripts/ci/manifest-audit/Cargo.toml index b9b5f8e47..4d539ce8a 100644 --- a/scripts/ci/manifest-audit/Cargo.toml +++ b/scripts/ci/manifest-audit/Cargo.toml @@ -68,5 +68,10 @@ toml = { workspace = true } # `src/main.rs` with `reason = "..."` strings, matching the precedent # established for `gen-hooks` / `gen-workflow` in PR #228. See # `CLIPPY_POSTURE.md` § CLI-tooling for the policy. +# Embeds the UFFS icon + version info + shared app.manifest into +# `manifest-audit.exe` (see build.rs) for branding consistency. +[build-dependencies] +winresource.workspace = true + [lints] workspace = true diff --git a/scripts/ci/manifest-audit/build.rs b/scripts/ci/manifest-audit/build.rs new file mode 100644 index 000000000..a7d25aefd --- /dev/null +++ b/scripts/ci/manifest-audit/build.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `manifest-audit`. +//! +//! Embeds the UFFS icon + version info + shared `app.manifest` into +//! `manifest-audit.exe` via [`winresource`](https://crates.io/crates/winresource), +//! for branding consistency with the rest of the UFFS binary family. +//! MSVC-Windows only; a no-op on every other build target. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set("FileDescription", "UFFS CI: manifest auditor") + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set_manifest_file("../../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed manifest-audit resources"); +} From f89443096a1441f87b10eb5ca5228ddc86c46f01 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:04:06 -0700 Subject: [PATCH 25/40] fix(mft): bound the overlapped $UpCase/FRS read with a dedicated event (fixes fresh-load hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the daemon's fresh (no-cache) load hanging at 5/6-of-7 drives: after the main MFT read, each drive re-reads the $UpCase table via read_handle_at -> read_handle_at_once, which issued an overlapped ReadFile on the broker's FILE_FLAG_OVERLAPPED handle with a NULL-hEvent OVERLAPPED and then GetOverlappedResult(bWait=true). With no event, GetOverlappedResult waits on the file object itself; the broker vends duplicate handles to the same volume file object, so under a concurrent multi-drive load it cannot tell which read completed and blocks FOREVER (Microsoft's documented pitfall). Debug logs showed 1-2 drives stall right after "Adopted Access Broker volume handle", never reaching "Parsed $UpCase data runs" — a silent, error-less hang. This affected every fresh `uffs --daemon start`, not just the uninstall. Fix: bind each read to a dedicated manual-reset event and wait on the event (never the shared handle), bounded by IOCP_WAIT_COMPLETION_DEADLINE. On timeout, CancelIoEx + drain, then return a retryable ERROR_OPERATION_ABORTED so the existing read_handle_at retry loop reissues it. The event makes the wait specific to this operation (the documented fix); the bound guarantees the load can never hang forever again — a genuinely wedged read fails fast and the daemon reaches Ready instead of stalling. Validated with cargo xwin clippy (x86_64-pc-windows-msvc). Co-Authored-By: Claude Opus 4.8 --- crates/uffs-mft/src/platform/volume.rs | 87 +++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index eae13dce3..861555f14 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -213,18 +213,59 @@ fn is_operation_aborted(err: &MftError) -> bool { /// [`MftError::Io`] if `ReadFile` / `GetOverlappedResult` fail, or /// [`MftError::InvalidData`] on a short read. #[cfg(windows)] -#[expect(unsafe_code, reason = "FFI: overlapped ReadFile + GetOverlappedResult")] +#[expect(unsafe_code, reason = "FFI: create/close a per-read completion event")] fn read_handle_at_once(handle: HANDLE, offset: u64, buf: &mut [u8]) -> Result<()> { - use windows::Win32::Foundation::ERROR_IO_PENDING; + use windows::Win32::System::Threading::CreateEventW; + + // Bind THIS read to a dedicated manual-reset event and wait on the event — + // never on the bare file handle. The Access Broker vends duplicate handles + // to the same volume file object, so during a fresh concurrent multi-drive + // load several overlapped reads race on that object; a NULL-event + // `GetOverlappedResult(bWait=true)` then cannot tell which read completed + // and blocks forever (Microsoft's documented pitfall). That was the post- + // read `$UpCase` read that silently hung 1-2 drives at 5-or-6-of-7 on every + // fresh (no-cache) daemon start. The event makes the wait specific to this + // read and lets us bound it. + // + // SAFETY: FFI. `CreateEventW` returns an owned event handle we close below. + let event = unsafe { CreateEventW(None, true, false, PCWSTR::null()) } + .map_err(|err| MftError::Io(hresult_to_io_error(&err)))?; + let outcome = read_handle_at_once_event(handle, offset, buf, event); + // SAFETY: FFI. `event` is the live event we created; close it exactly once. + unsafe { + let _closed = CloseHandle(event); + } + outcome +} + +/// Body of [`read_handle_at_once`] given a dedicated completion `event`, split +/// out so the caller closes the event on every return path. +/// +/// # Errors +/// +/// [`MftError::Io`] if `ReadFile` / the wait / `GetOverlappedResult` fail — an +/// overrun of [`IOCP_WAIT_COMPLETION_DEADLINE`] surfaces as a retryable +/// `ERROR_OPERATION_ABORTED` — or [`MftError::InvalidData`] on a short read. +#[cfg(windows)] +#[expect(unsafe_code, reason = "FFI: overlapped ReadFile + event-bounded wait")] +fn read_handle_at_once_event( + handle: HANDLE, + offset: u64, + buf: &mut [u8], + event: HANDLE, +) -> Result<()> { + use windows::Win32::Foundation::{ERROR_IO_PENDING, WAIT_OBJECT_0, WAIT_TIMEOUT}; use windows::Win32::Storage::FileSystem::ReadFile; - use windows::Win32::System::IO::{GetOverlappedResult, OVERLAPPED}; + use windows::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}; + use windows::Win32::System::Threading::WaitForSingleObject; let mut overlapped = OVERLAPPED::default(); crate::io::readers::set_overlapped_offset(&mut overlapped, offset); + overlapped.hEvent = event; let mut bytes_read = 0_u32; - // SAFETY: `buf` is valid and writable for its length; `overlapped` outlives - // the call and the wait below; `handle` is a live volume handle. + // SAFETY: `buf` is valid+writable for its length; `overlapped` (with its + // event) outlives the call and the wait; `handle` is a live volume handle. let read = unsafe { ReadFile( handle, @@ -237,9 +278,39 @@ fn read_handle_at_once(handle: HANDLE, offset: u64, buf: &mut [u8]) -> Result<() if err.code() != ERROR_IO_PENDING.to_hresult() { return Err(MftError::Io(hresult_to_io_error(&err))); } - // SAFETY: `overlapped` is the in-flight struct from the pending - // `ReadFile` and is still alive; `bWait = true` blocks to completion. - unsafe { GetOverlappedResult(handle, &raw const overlapped, &raw mut bytes_read, true) } + let deadline_ms = + u32::try_from(IOCP_WAIT_COMPLETION_DEADLINE.as_millis()).unwrap_or(u32::MAX); + // SAFETY: FFI. `event` is the manual-reset event bound to `overlapped`. + let wait = unsafe { WaitForSingleObject(event, deadline_ms) }; + if wait == WAIT_TIMEOUT { + // The read wedged. Cancel it and drain the cancellation so the + // kernel stops referencing `buf` / `overlapped` before they drop, + // then report a retryable abort (995) that `read_handle_at` + // reissues. + // SAFETY: FFI. Cancel the in-flight read on this handle+overlapped. + unsafe { + let _cancelled = CancelIoEx(handle, Some(&raw const overlapped)); + } + // SAFETY: FFI. Drain the cancellation (blocks via the bound event + // until it settles) so the kernel stops referencing `buf` / + // `overlapped` before they drop. + unsafe { + let _drained = + GetOverlappedResult(handle, &raw const overlapped, &raw mut bytes_read, true); + } + return Err(MftError::Io(std::io::Error::from_raw_os_error( + i32::try_from(ERROR_OPERATION_ABORTED_CODE).unwrap_or(995), + ))); + } + if wait != WAIT_OBJECT_0 { + return Err(MftError::Io(std::io::Error::other(format!( + "overlapped read wait failed: WaitForSingleObject returned 0x{:08X}", + wait.0 + )))); + } + // Signaled: collect the result without waiting further. + // SAFETY: FFI. `overlapped` is the completed in-flight struct. + unsafe { GetOverlappedResult(handle, &raw const overlapped, &raw mut bytes_read, false) } .map_err(|wait_err| MftError::Io(hresult_to_io_error(&wait_err)))?; } if (bytes_read as usize) < buf.len() { From 4fc9a6015db2cfec3a01dfcb7790e08ca351c7fa Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:57:21 -0700 Subject: [PATCH 26/40] =?UTF-8?q?feat(uninstall):=20quieter=20UX=20?= =?UTF-8?q?=E2=80=94=20elevation=20asked=20first,=20one=20final=20summary,?= =?UTF-8?q?=20-v=20for=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive flow was noisy and the elevation choice confusing: the full binary table, inventory, and plan (broker item included, "(needs Administrator)") printed BEFORE a double-clause elevation question, and answering y echoed "Leaving the broker installed" mid-flow. Restructure to: decide -> gather -> summarize -> confirm. - Elevation is THE FIRST question (right after the header, before any analysis output): list what needs an Administrator terminal and why, then one clear choice — continue without it, or abort to re-run elevated. `--yes` continues without asking. Declined items are dropped from the plan entirely, so the summary never shows work that will not happen. - Default output is compact: a one-line scan summary replaces the resolution table + inventory; the `[sweep]` diagnostics are silent. New `-v/--verbose` restores the full tables and sweep detail (dbg_line now verbose-gated). - The removal plan prints ONCE, at the very end after the deep sweep, as the final "here is what this run will do" — followed by a "NOT removed in this run (needs Administrator)" note for anything skipped at the gate, then the strays opt-in and the single final confirmation. - Dry-run keeps the complete preview (admin markers intact) plus a note that a real non-elevated run asks up front. drop_elevation_required now returns the dropped items' descriptions for the summary note. Validated with cargo xwin clippy (prod + tests) and the uninstall unit tests (52 passed). Co-Authored-By: Claude Fable 5 --- .../uffs-cli/src/commands/uninstall/args.rs | 11 +++ crates/uffs-cli/src/commands/uninstall/mod.rs | 75 +++++++++++++------ .../uffs-cli/src/commands/uninstall/plan.rs | 22 +++++- .../uffs-cli/src/commands/uninstall/render.rs | 58 ++++++++++++-- .../uffs-cli/src/commands/uninstall/sweep.rs | 21 ++++-- 5 files changed, 147 insertions(+), 40 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/args.rs b/crates/uffs-cli/src/commands/uninstall/args.rs index 69175def1..6c965e91f 100644 --- a/crates/uffs-cli/src/commands/uninstall/args.rs +++ b/crates/uffs-cli/src/commands/uninstall/args.rs @@ -56,6 +56,9 @@ pub(crate) struct UninstallArgs { pub(crate) no_path: bool, /// `--json`: emit the analysis + plan as machine-readable JSON. pub(crate) json: bool, + /// `-v` / `--verbose`: show the full binary resolution table, artifact + /// inventory, and deep-sweep diagnostics (default: a one-line summary). + pub(crate) verbose: bool, /// `--scope`: restrict to user / machine / all (default `all`). pub(crate) scope: UninstallScope, /// `--help` / `-h`: print usage and exit. @@ -80,6 +83,7 @@ impl UninstallArgs { "--no-deep-sweep" => parsed.no_deep_sweep = true, "--no-path" => parsed.no_path = true, "--json" => parsed.json = true, + "--verbose" | "-v" => parsed.verbose = true, "--help" | "-h" => parsed.help = true, "--scope" => { let value = iter @@ -124,6 +128,7 @@ mod tests { "--no-deep-sweep", "--no-path", "--json", + "--verbose", ]) .unwrap(); assert!( @@ -133,9 +138,15 @@ mod tests { && out.no_deep_sweep && out.no_path && out.json + && out.verbose ); } + #[test] + fn verbose_short_form_maps() { + assert!(parse(&["-v"]).unwrap().verbose); + } + #[test] fn yes_aliases_all_map() { for tok in ["--yes", "--assume-yes", "-y"] { diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index effce0103..e1c27b2b7 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -78,30 +78,21 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { } render::print_run_header(); - render::print_resolution_table(&resolved); - render::print_inventory(&inventory); - render::print_plan(&removal_plan); - // M3 elevation (U-30): the broker (its LocalSystem service) is the only - // admin-only part. Decide it UP FRONT — *before* the slow deep sweep — so a - // non-elevated run is told immediately and isn't left to discover it at the - // end. An elevated run skips this and removes everything. Dry-run only - // previews (the plan already marks the broker "needs Administrator"). - // `uffs_mft::platform::is_elevated` is cross-platform (Windows token check; - // Unix effective-uid 0). - if !parsed.dry_run && removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() { - render::print_elevation_required(&removal_plan); - if confirm( - "\nRemoving these needs Administrator. Continue now and uninstall everything\n\ - ELSE, leaving them? (answering No aborts so you can re-run elevated) [y/N] ", - )? { - removal_plan.drop_elevation_required(); - render::print_broker_kept(); - } else { - bail!( - "aborted — re-run `uffs --uninstall` from an elevated (Administrator) terminal to remove everything" - ); - } + // `-v` also unlocks the deep-sweep diagnostics printed via + // [`sweep::dbg_line`] during the stray search below. + #[cfg(windows)] + sweep::set_verbose(parsed.verbose); + + let skipped_elevation = elevation_gate(&parsed, &mut removal_plan)?; + + // Scan overview: a one-line summary by default; the full binary resolution + // table + artifact inventory under `-v`. + if parsed.verbose { + render::print_resolution_table(&resolved); + render::print_inventory(&inventory); + } else { + render::print_scan_summary(&resolved, &inventory); } // M7 deep sweep: ask UFFS itself for stray family files elsewhere on the @@ -112,7 +103,16 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // plan above) are all we can find. let stray_plan = platform_stray_plan(&parsed, &removal_plan); + // The FINAL summary — everything is gathered, so say exactly what this run + // will (and will not) do, then ask. The stray list printed just above by + // the sweep is part of this picture. + render::print_plan(&removal_plan); + render::print_skipped_elevation(&skipped_elevation); + if parsed.dry_run { + if removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() { + render::print_dry_run_elevation_note(); + } print_dry_run_footer(); return Ok(()); } @@ -202,6 +202,34 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { Ok(()) } +/// M3 elevation gate (U-30): THE FIRST question, before any analysis output. +/// The broker (its `LocalSystem` service) is the only admin-only part; a +/// non-elevated run is told immediately what it cannot remove and decides once +/// whether to continue without it. The skipped items are dropped from the plan +/// entirely (so the final summary never lists work that will not happen) and +/// their descriptions are returned for the summary's "NOT removed in this run" +/// note. Empty when elevated, under `--dry-run` (preview keeps the markers), or +/// when nothing needs Administrator. `--yes` continues without asking. +/// `uffs_mft::platform::is_elevated` is cross-platform (Windows token check; +/// Unix effective-uid 0). +fn elevation_gate(parsed: &UninstallArgs, removal_plan: &mut RemovalPlan) -> Result> { + if parsed.dry_run || !removal_plan.requires_elevation() || uffs_mft::platform::is_elevated() { + return Ok(Vec::new()); + } + render::print_elevation_gate(removal_plan); + let continue_without = parsed.assume_yes + || confirm( + "\nContinue without Administrator? Everything else is still uninstalled; the\n\ + item(s) above are left in place. (No aborts so you can re-run elevated) [y/N] ", + )?; + if !continue_without { + bail!( + "aborted — re-run `uffs --uninstall` from an elevated (Administrator) terminal to remove everything" + ); + } + Ok(removal_plan.drop_elevation_required()) +} + /// The running self-binaries that cannot be deleted in place: the current /// `uffs` executable and its sibling `uffs-update`. fn self_binaries() -> Vec { @@ -339,6 +367,7 @@ fn print_help() { \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 --verbose, -v Show the full binary table, inventory, and sweep detail\n\ \x20 --help, -h Show this help" ); } diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index 37c27bbc9..6e2b90140 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -184,12 +184,22 @@ impl RemovalPlan { /// Drop every item that needs Administrator (the broker service + its /// process), removing any group left empty. Lets a non-elevated run remove - /// everything it *can* and leave the broker for an elevated re-run. - pub(crate) fn drop_elevation_required(&mut self) { + /// everything it *can* and leave the broker for an elevated re-run. Returns + /// the dropped items' descriptions so the final summary can list exactly + /// what this run skips. + pub(crate) fn drop_elevation_required(&mut self) -> Vec { + let mut dropped: Vec = Vec::new(); for group in &mut self.groups { - group.items.retain(|item| !item.needs_elevation); + group.items.retain(|item| { + if item.needs_elevation { + dropped.push(item.target.describe()); + return false; + } + true + }); } self.groups.retain(|group| !group.items.is_empty()); + dropped } /// Number of items across all groups. @@ -645,8 +655,12 @@ mod tests { "broker service + process need admin" ); - plan.drop_elevation_required(); + let dropped = plan.drop_elevation_required(); assert!(!plan.requires_elevation(), "admin-only items were dropped"); + assert!( + !dropped.is_empty() && dropped.iter().all(|desc| !desc.is_empty()), + "the dropped items are returned as human descriptions for the summary" + ); assert!( !has_target(&plan, |target| matches!( target, diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index ed4b99c85..3706b5d69 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -186,11 +186,15 @@ pub(crate) fn print_plan(plan: &RemovalPlan) { ); } -/// List the admin-only items (the broker service + its process) before the -/// non-elevated keep-or-elevate choice (U-30). +/// The up-front elevation gate (U-30): the FIRST thing a non-elevated run says. +/// Explains which items need an Administrator terminal and why, before any +/// analysis output — the question that follows is the only elevation decision. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_elevation_required(plan: &RemovalPlan) { - println!("\nThese items need Administrator (the broker runs as LocalSystem):"); +pub(crate) fn print_elevation_gate(plan: &RemovalPlan) { + println!( + "\nThis terminal is not elevated (Administrator). The following can only be\n\ + removed from an elevated terminal (the broker runs as LocalSystem):" + ); for group in &plan.groups { for item in &group.items { if item.needs_elevation { @@ -200,12 +204,50 @@ pub(crate) fn print_elevation_required(plan: &RemovalPlan) { } } -/// Note printed when the user keeps the broker and continues non-elevated. +/// Final-summary note listing what this run skips because it needs +/// Administrator (decided once, up front, at the elevation gate). +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_skipped_elevation(skipped: &[String]) { + if skipped.is_empty() { + return; + } + println!("\nNOT removed in this run (needs Administrator):"); + for item in skipped { + println!(" - {item}"); + } + println!(" Re-run `uffs --uninstall` from an elevated terminal to remove these."); +} + +/// One-line scan overview printed by default in place of the full resolution +/// table + inventory (which move behind `-v`): how much was found, and where. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_scan_summary(stems: &[StemResolution], inventory: &Inventory) { + let copies: usize = stems.iter().map(|stem| stem.copies.len()).sum(); + let mut dirs: Vec = stems + .iter() + .flat_map(|stem| { + stem.copies + .iter() + .map(|copy| copy.dir.to_string_lossy().to_ascii_lowercase()) + }) + .collect(); + dirs.sort_unstable(); + dirs.dedup(); + let data_dirs = inventory.dirs.iter().filter(|dir| dir.exists).count(); + println!( + "\nFound {copies} UFFS binaries in {} location(s) and {data_dirs} data/cache \ + location(s). (-v for the full inventory)", + dirs.len(), + ); +} + +/// Dry-run note shown when the plan carries admin-only items but this terminal +/// is not elevated: a real run will offer to skip them. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_broker_kept() { +pub(crate) fn print_dry_run_elevation_note() { println!( - "Leaving the broker installed. Re-run `uffs --uninstall` from an elevated \ - terminal to remove it." + "\nNote: items marked (needs Administrator) require an elevated terminal; a\n\ + non-elevated run asks up front whether to continue without them." ); } diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index 96f6c1ddc..2a3f1126b 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -18,12 +18,23 @@ use std::time::Instant; use anyhow::Result; -/// TEMPORARY uninstall deep-sweep diagnostics. Prints a `[sweep]` line to -/// stdout so we can see candidate counts / phase timings during the Windows -/// rollout. Remove once the sweep is signed off. -#[expect(clippy::print_stdout, reason = "temporary deep-sweep diagnostics")] +/// Gate for the `[sweep]` diagnostic lines: set from `-v` once at the start of +/// an uninstall run, read by [`dbg_line`]. A relaxed static rather than a +/// parameter so the sweep call chain (and its tests) stay signature-stable. +static SWEEP_VERBOSE: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); + +/// Enable/disable the `[sweep]` diagnostics for this run (`-v` / `--verbose`). +pub(crate) fn set_verbose(verbose: bool) { + SWEEP_VERBOSE.store(verbose, core::sync::atomic::Ordering::Relaxed); +} + +/// Deep-sweep diagnostics (candidate counts per pattern, phase timings, probe +/// timeouts). Prints a `[sweep]` line to stdout, only under `-v`. +#[expect(clippy::print_stdout, reason = "verbose-gated deep-sweep diagnostics")] pub(crate) fn dbg_line(msg: &str) { - println!(" [sweep] {msg}"); + if SWEEP_VERBOSE.load(core::sync::atomic::Ordering::Relaxed) { + println!(" [sweep] {msg}"); + } } /// UFFS cache/cursor data-file patterns the sweep searches for. The executable From 9bd28a0ff73c696cd9eb45ab17aff2dd1bb1dfbf Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:23:40 -0700 Subject: [PATCH 27/40] =?UTF-8?q?feat(uninstall):=20one-click=20elevate=20?= =?UTF-8?q?=E2=80=94=203-way=20gate=20+=20UAC=20helper=20at=20removal=20ti?= =?UTF-8?q?me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The elevation gate previously offered only continue-without or abort. Windows cannot elevate a running process in place, so add the middle path the user actually wants: elevate exactly what needs it, exactly when it is needed. - Gate becomes a 3-way choice on Windows (still the FIRST question): e = elevate at removal time (one UAC prompt), c = continue without, a = abort (default). Non-Windows keeps the binary continue/abort. `--yes` still means continue-without — a scripted run must never pop a surprise UAC. - Choosing `e` only records the decision: the admin items stay in the plan (final summary notes "will show one Windows UAC prompt when removal starts"), and the whole flow continues in the same window. Nothing elevates before the final confirmation. - At execution, SystemEffects::remove_service routes through a one-shot elevated helper: PowerShell `Start-Process -Verb RunAs -Wait -PassThru` relaunches uffs.exe in the hidden `--uninstall --remove-service-helper ` mode (refuses to run non-elevated; performs the exact same stop+delete as the elevated in-process path), then verifies the service is actually gone. Keeps the crate unsafe-free per the module's shell-out design. - A declined UAC prompt (catch -> exit 223) degrades gracefully: the item is reported as skipped with the elevated re-run hint, everything else is still removed, and no mid-execution question is asked (decisions stay up front). - The helper never touches binaries, so there is no self-delete race with the waiting parent process. Validated with cargo clippy (host) + cargo xwin clippy (Windows, prod + tests); uninstall unit tests pass (38), including the hidden-flag parse test. Co-Authored-By: Claude Fable 5 --- .../uffs-cli/src/commands/uninstall/args.rs | 23 +++ .../src/commands/uninstall/effects.rs | 96 +++++++++- crates/uffs-cli/src/commands/uninstall/mod.rs | 179 ++++++++++++++---- .../uffs-cli/src/commands/uninstall/render.rs | 11 ++ 4 files changed, 267 insertions(+), 42 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/args.rs b/crates/uffs-cli/src/commands/uninstall/args.rs index 6c965e91f..1f6aa8fb9 100644 --- a/crates/uffs-cli/src/commands/uninstall/args.rs +++ b/crates/uffs-cli/src/commands/uninstall/args.rs @@ -63,6 +63,11 @@ pub(crate) struct UninstallArgs { pub(crate) scope: UninstallScope, /// `--help` / `-h`: print usage and exit. pub(crate) help: bool, + /// `--remove-service-helper `: **internal, undocumented.** The + /// elevated child mode spawned via UAC by the non-elevated uninstall's + /// "elevate at removal time" choice: remove exactly this Windows service, + /// then exit. Never passed by users; deliberately absent from `--help`. + pub(crate) admin_helper_service: Option, } impl UninstallArgs { @@ -91,6 +96,12 @@ impl UninstallArgs { .ok_or_else(|| anyhow!("--scope requires a value: user | machine | all"))?; parsed.scope = UninstallScope::parse(value)?; } + "--remove-service-helper" => { + let value = iter.next().ok_or_else(|| { + anyhow!("--remove-service-helper requires a service name") + })?; + parsed.admin_helper_service = Some(value.clone()); + } flag if flag.starts_with("--scope=") => { let value = flag.strip_prefix("--scope=").unwrap_or_default(); parsed.scope = UninstallScope::parse(value)?; @@ -147,6 +158,18 @@ mod tests { assert!(parse(&["-v"]).unwrap().verbose); } + #[test] + fn hidden_service_helper_flag_parses_and_requires_a_name() { + assert_eq!( + parse(&["--remove-service-helper", "UffsAccessBroker"]) + .unwrap() + .admin_helper_service + .as_deref(), + Some("UffsAccessBroker") + ); + parse(&["--remove-service-helper"]).unwrap_err(); + } + #[test] fn yes_aliases_all_map() { for tok in ["--yes", "--assume-yes", "-y"] { diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index 5b4d8369a..6f21905a4 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -24,13 +24,30 @@ use crate::commands::update::model::Scope; pub(crate) struct SystemEffects { /// Absolute paths of the running self-binaries to skip in-place deletes. self_paths: Vec, + /// Windows: the user chose "elevate at removal time" at the elevation gate, + /// so admin-only service removal is routed through a one-shot elevated + /// helper (a single UAC prompt) instead of failing non-elevated. Stored but + /// never read off Windows (no broker service exists there). + #[cfg_attr( + not(windows), + expect( + dead_code, + reason = "read only by the Windows UAC service-removal routing" + ) + )] + elevate_via_uac: bool, } impl SystemEffects { /// Construct the live effects sink, told which running self-binaries to - /// skip in-place (they are deferred to [`schedule_self_delete`]). - pub(crate) const fn new(self_paths: Vec) -> Self { - Self { self_paths } + /// skip in-place (they are deferred to [`schedule_self_delete`]) and + /// whether admin-only service removal goes through the Windows UAC helper + /// (`elevate_via_uac`; meaningless off Windows). + pub(crate) const fn new(self_paths: Vec, elevate_via_uac: bool) -> Self { + Self { + self_paths, + elevate_via_uac, + } } /// Whether `path` is one of the running self-binaries (case-insensitive, @@ -49,6 +66,13 @@ impl Effects for SystemEffects { } fn remove_service(&mut self, service: &str) -> Result<()> { + // Non-elevated with the gate's "elevate at removal time" choice: run + // the removal in a one-shot elevated helper (this is where the single + // UAC prompt appears). Elevated runs remove the service in-process. + #[cfg(windows)] + if self.elevate_via_uac && !uffs_mft::is_elevated() { + return remove_service_via_uac(service); + } remove_windows_service(service) } @@ -241,9 +265,11 @@ fn stop_command(pid_str: &str) -> Command { } /// Stop + delete the broker Windows service. No-op off Windows (where no such -/// service exists, so the plan never produces this item). +/// service exists, so the plan never produces this item). `pub(crate)` so the +/// hidden `--remove-service-helper` mode (the elevated UAC child) can call the +/// exact same removal. #[cfg(windows)] -fn remove_windows_service(service: &str) -> Result<()> { +pub(crate) fn remove_windows_service(service: &str) -> Result<()> { // Best-effort stop first; an already-stopped service is fine to delete, so // proceed whether or not the stop succeeded. match uffs_winsvc::stop(service) { @@ -259,10 +285,66 @@ fn remove_windows_service(service: &str) -> Result<()> { /// 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<()> { +pub(crate) fn remove_windows_service(service: &str) -> Result<()> { bail!("cannot remove service {service}: the broker is Windows-only") } +/// Marker exit code the `PowerShell` launcher script returns when elevation was +/// not obtained (the UAC prompt was declined, or `Start-Process -Verb RunAs` +/// failed) — distinguishable from the helper's own success (0) / failure (1). +#[cfg(windows)] +const UAC_NOT_GRANTED_EXIT: i32 = 223; + +/// Remove `service` through a one-shot **elevated helper**: relaunch this same +/// `uffs.exe` via `Start-Process -Verb RunAs` (the single UAC prompt) with the +/// hidden `--uninstall --remove-service-helper ` mode, wait for it, +/// and map its exit code. A declined UAC prompt degrades gracefully into an +/// error that names the skipped service and the elevated re-run hint — the +/// executor records it and the rest of the uninstall continues. +/// +/// `PowerShell` (not raw `ShellExecuteExW`) keeps this crate `unsafe`-free and +/// matches the module's shell-out design; `-Wait -PassThru` provides the exit +/// code, and the `catch` arm turns "UAC declined" into +/// [`UAC_NOT_GRANTED_EXIT`]. +#[cfg(windows)] +fn remove_service_via_uac(service: &str) -> Result<()> { + let raw_exe = std::env::current_exe().context("locating uffs.exe for the elevated helper")?; + let exe = crate::commands::update::strip_verbatim_prefix(raw_exe); + let exe_escaped = exe.display().to_string().replace('\'', "''"); + let service_escaped = service.replace('\'', "''"); + let script = format!( + "try {{ \ + $p = Start-Process -FilePath '{exe_escaped}' \ + -ArgumentList '--uninstall','--remove-service-helper','{service_escaped}' \ + -Verb RunAs -Wait -PassThru -WindowStyle Hidden; \ + exit $p.ExitCode \ + }} catch {{ exit {UAC_NOT_GRANTED_EXIT} }}" + ); + let status = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("spawning the elevated service-removal helper")?; + match status.code() { + Some(0) => { + // Trust but verify: the helper said OK, confirm the service is gone. + if uffs_winsvc::is_installed(service) { + bail!("elevated helper reported success but service {service} is still installed"); + } + Ok(()) + } + Some(UAC_NOT_GRANTED_EXIT) => bail!( + "elevation was not granted (UAC declined) — {service} was left installed; \ + re-run `uffs --uninstall` from an Administrator terminal to remove it" + ), + other => bail!( + "elevated service-removal helper failed (exit {other:?}) — {service} may still \ + be installed" + ), + } +} + /// 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"); @@ -307,7 +389,7 @@ mod tests { // The second stem is treated as the running self-binary — it must be // skipped (left for the deferred self-delete), not removed in place. let self_path = base.join(exe_file_name("uffsd")); - let mut effects = SystemEffects::new(vec![self_path.clone()]); + let mut effects = SystemEffects::new(vec![self_path.clone()], false); effects.delete_binaries(&base, &stems).unwrap(); assert!( !base.join(exe_file_name("uffs")).exists(), diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index e1c27b2b7..e9152bfdd 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -47,6 +47,13 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { print_help(); return Ok(()); } + // Hidden elevated-child mode (see `UninstallArgs::admin_helper_service`): + // remove exactly the named service and exit. Spawned via UAC by the + // effects layer's service-removal routing; never part of the interactive + // flow. + if let Some(service) = parsed.admin_helper_service.as_deref() { + return run_admin_helper(service); + } // M9 crash-awareness: if a prior uninstall was interrupted, say so. Because // removal is idempotent, this (re-)run simply completes it. @@ -54,23 +61,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { render::print_resumed_note(); } - // M1 analysis: reuse the self-update Phase-A detection for the binary - // resolution table, then sweep in any retired/optional binary names that - // linger from old installs, then inventory the non-binary artifacts. - let mut report = crate::commands::update::detect(); - // Scan PATH + the standard bin dirs for copies that are neither running nor - // the invoking exe (which-style, stat-only — no filesystem walk), then sweep - // in any retired/optional binary names that linger from old installs. - analyze::augment_with_path_locations(&mut report); - analyze::augment_with_extra_binaries(&mut report); - 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). Only PATH - // entries pointing at a *dedicated* UFFS dir are offered for removal — a - // shared bin dir (~/bin, ~/.local/bin) we never created is left alone. - let removable_path = analyze::removable_path_dirs(&report, &analyze::path_entries()); - let mut removal_plan = plan::build_plan(&report, &inventory, &parsed, &removable_path); + let (resolved, inventory, mut removal_plan) = analyze_and_plan(&parsed); if parsed.json { render::print_json(&resolved, &inventory, &removal_plan); @@ -84,7 +75,11 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { #[cfg(windows)] sweep::set_verbose(parsed.verbose); - let skipped_elevation = elevation_gate(&parsed, &mut removal_plan)?; + let gate = elevation_gate(&parsed, &mut removal_plan)?; + let skipped_elevation: Vec = match &gate { + ElevationChoice::ContinueWithout(items) => items.clone(), + ElevationChoice::NotNeeded | ElevationChoice::ElevateAtRemoval => Vec::new(), + }; // Scan overview: a one-line summary by default; the full binary resolution // table + artifact inventory under `-v`. @@ -108,6 +103,9 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // the sweep is part of this picture. render::print_plan(&removal_plan); render::print_skipped_elevation(&skipped_elevation); + if matches!(gate, ElevationChoice::ElevateAtRemoval) { + render::print_uac_note(); + } if parsed.dry_run { if removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() { @@ -157,7 +155,10 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // M4 execute (U-40..42): run the plan(s) once against the live effects sink, // accumulating a single outcome so the summary + retry hint print once. - let mut effects = effects::SystemEffects::new(self_paths.clone()); + let mut effects = effects::SystemEffects::new( + self_paths.clone(), + matches!(gate, ElevationChoice::ElevateAtRemoval), + ); let mut outcome = remove::RemovalOutcome::default(); if !removal_plan.is_empty() { outcome.absorb(remove::execute(&removal_plan, &mut effects)); @@ -202,32 +203,140 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { Ok(()) } +/// M1+M2 analysis (read-only, no output): reuse the self-update Phase-A +/// detection, sweep in PATH/standard-location copies and retired/optional +/// binary names lingering from old installs, inventory the non-binary +/// artifacts, and build the ordered removal plan. Only PATH entries pointing +/// at a *dedicated* UFFS dir are offered for removal — a shared bin dir +/// (`~/bin`, `~/.local/bin`) we never created is left alone. +fn analyze_and_plan( + parsed: &UninstallArgs, +) -> ( + Vec, + inventory::Inventory, + RemovalPlan, +) { + let mut report = crate::commands::update::detect(); + analyze::augment_with_path_locations(&mut report); + analyze::augment_with_extra_binaries(&mut report); + let candidates = analyze::build_candidates(&report); + let resolved = resolve_order::group_and_resolve(&candidates, &analyze::search_dirs()); + let inventory = inventory::collect(); + let removable_path = analyze::removable_path_dirs(&report, &analyze::path_entries()); + let removal_plan = plan::build_plan(&report, &inventory, parsed, &removable_path); + (resolved, inventory, removal_plan) +} + +/// What the elevation gate decided for this run. +enum ElevationChoice { + /// Elevated, `--dry-run`, or nothing needs Administrator — plan untouched. + NotNeeded, + /// Windows, non-elevated: keep the admin items in the plan; removal routes + /// them through a one-shot elevated helper (a single UAC prompt at removal + /// time — see [`effects`]). + #[cfg_attr( + not(windows), + expect(dead_code, reason = "constructed only on the Windows UAC path") + )] + ElevateAtRemoval, + /// Non-elevated, continuing without the admin items: they are dropped from + /// the plan; carries their descriptions for the final summary's "NOT + /// removed in this run" note. + ContinueWithout(Vec), +} + /// M3 elevation gate (U-30): THE FIRST question, before any analysis output. /// The broker (its `LocalSystem` service) is the only admin-only part; a -/// non-elevated run is told immediately what it cannot remove and decides once -/// whether to continue without it. The skipped items are dropped from the plan -/// entirely (so the final summary never lists work that will not happen) and -/// their descriptions are returned for the summary's "NOT removed in this run" -/// note. Empty when elevated, under `--dry-run` (preview keeps the markers), or -/// when nothing needs Administrator. `--yes` continues without asking. +/// non-elevated run is told immediately what needs Administrator and decides +/// once — elevate at removal time (Windows: one UAC prompt), continue without +/// (items dropped so the final summary never lists work that will not happen), +/// or abort. Skipped when elevated, under `--dry-run` (preview keeps the +/// markers), or when nothing needs Administrator. `--yes` continues without +/// asking — a scripted run must never trigger a surprise UAC prompt. /// `uffs_mft::platform::is_elevated` is cross-platform (Windows token check; /// Unix effective-uid 0). -fn elevation_gate(parsed: &UninstallArgs, removal_plan: &mut RemovalPlan) -> Result> { +fn elevation_gate( + parsed: &UninstallArgs, + removal_plan: &mut RemovalPlan, +) -> Result { if parsed.dry_run || !removal_plan.requires_elevation() || uffs_mft::platform::is_elevated() { - return Ok(Vec::new()); + return Ok(ElevationChoice::NotNeeded); } render::print_elevation_gate(removal_plan); - let continue_without = parsed.assume_yes - || confirm( - "\nContinue without Administrator? Everything else is still uninstalled; the\n\ - item(s) above are left in place. (No aborts so you can re-run elevated) [y/N] ", - )?; - if !continue_without { - bail!( + if parsed.assume_yes { + return Ok(ElevationChoice::ContinueWithout( + removal_plan.drop_elevation_required(), + )); + } + platform_elevation_choice(removal_plan) +} + +/// Windows: the interactive 3-way elevation choice. `e` records the decision — +/// the single UAC prompt appears later, when removal actually starts, so +/// nothing is elevated before the final confirmation. +#[cfg(windows)] +fn platform_elevation_choice(removal_plan: &mut RemovalPlan) -> Result { + let choice = prompt_choice( + "\n e = elevate at removal time (Windows shows one UAC prompt)\n\ + \x20 c = continue without it (the item(s) above stay installed)\n\ + \x20 a = abort\n\ + Choice [e/c/A]: ", + )?; + match choice.as_str() { + "e" | "elevate" => Ok(ElevationChoice::ElevateAtRemoval), + "c" | "continue" => Ok(ElevationChoice::ContinueWithout( + removal_plan.drop_elevation_required(), + )), + _ => bail!( "aborted — re-run `uffs --uninstall` from an elevated (Administrator) terminal to remove everything" + ), + } +} + +/// Non-Windows: there is no UAC to request, so the choice stays binary — +/// continue without the elevation-required items, or abort to re-run elevated. +#[cfg(not(windows))] +fn platform_elevation_choice(removal_plan: &mut RemovalPlan) -> Result { + if confirm( + "\nContinue without elevation? Everything else is still uninstalled; the\n\ + item(s) above are left in place. (No aborts so you can re-run elevated) [y/N] ", + )? { + Ok(ElevationChoice::ContinueWithout( + removal_plan.drop_elevation_required(), + )) + } else { + bail!("aborted — re-run `uffs --uninstall` elevated (sudo) to remove everything") + } +} + +/// Read one line of input for a multi-choice prompt, trimmed and lowercased. +#[cfg(windows)] +#[expect(clippy::print_stdout, reason = "interactive CLI prompt")] +fn prompt_choice(prompt: &str) -> Result { + use std::io::Write as _; + + print!("{prompt}"); + std::io::stdout() + .flush() + .context("flushing the choice prompt")?; + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .context("reading the choice")?; + Ok(line.trim().to_ascii_lowercase()) +} + +/// Hidden `--remove-service-helper` mode: the elevated child spawned (via a UAC +/// prompt) by [`effects`]' service-removal routing. Performs exactly the same +/// removal the elevated in-process path uses, then exits; refuses to run +/// non-elevated as a guard against direct invocation. +fn run_admin_helper(service: &str) -> Result<()> { + if !uffs_mft::platform::is_elevated() { + bail!( + "--remove-service-helper must run elevated (it is spawned via a UAC prompt by `uffs --uninstall`)" ); } - Ok(removal_plan.drop_elevation_required()) + effects::remove_windows_service(service) } /// The running self-binaries that cannot be deleted in place: the current diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 3706b5d69..0cce02755 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -218,6 +218,17 @@ pub(crate) fn print_skipped_elevation(skipped: &[String]) { println!(" Re-run `uffs --uninstall` from an elevated terminal to remove these."); } +/// Note printed under the final summary when the user chose "elevate at +/// removal time" at the gate: exactly one UAC prompt appears once removal +/// starts (never before the final confirmation). +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_uac_note() { + println!( + "\nThe item(s) marked (needs Administrator) will show one Windows UAC prompt\n\ + when removal starts." + ); +} + /// One-line scan overview printed by default in place of the full resolution /// table + inventory (which move behind `-v`): how much was found, and where. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] From 4c31b782680f32a878c8078b3088bee037cdc2f5 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:09:27 -0700 Subject: [PATCH 28/40] feat(uninstall): background gather + spinner, CORE/EXTRA tables, 3-way final choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX upgrades to the interactive flow (decide -> gather -> present -> confirm): 1. No wasted wall-clock: the drive-coverage reload + deep sweep start on a background thread the instant `--uninstall` fires, overlapping the elevation question. The daemon handlers gain a quiet mode (daemon_mgmt::daemon_quiet, RAII-reset static) and coverage defers its narration as notes, so background work never prints over the prompt. After the gate, a small spinner ("Gathering artifacts (indexing the drives / searching the drives)...") runs until the gather finishes. `-v` keeps the sequential, live-printing flow for diagnosis; a panicked gather degrades to "no strays". 2. Nothing is shown until everything is gathered, then the COMPLETE picture prints at once as two aligned table sections: CORE (the install — binary resolution table + data/cache inventory) and EXTRA (deep-sweep strays, now a BINARY / VERSION / LOCATION table matching CORE's shape), followed by the action plan and the gate notes. 3. The two trailing questions collapse into one 3-way tied to the sections: a = ALL (CORE + EXTRA), c = CORE only, q/Enter = ABORT. Without EXTRA files it stays the classic "Proceed with removal? [y/N]". `--yes` still means ALL. Execution extracted into execute_all (no prompts past consent). The quiet-mode plumbing pushed daemon_mgmt.rs past the 800-LOC budget; fixed at the root by decomposing, not excepting: the read-only status/stats rendering (daemon_status, print_drive_line, tier_marker, print_not_running, daemon_stats, compute_hit_rate_percent) moves to the new sibling commands/daemon_status.rs (254 LOC), leaving daemon_mgmt.rs at 625 LOC with dispatch, the elevation gate, and the mutating handlers. Display code unchanged byte-for-byte. Validated with cargo clippy (host) + cargo xwin clippy (Windows, prod + tests); 44 unit tests pass; file-size gate passes with no new exception. Co-Authored-By: Claude Fable 5 --- crates/uffs-cli/src/commands.rs | 1 + crates/uffs-cli/src/commands/daemon_mgmt.rs | 322 ++++-------------- crates/uffs-cli/src/commands/daemon_status.rs | 254 ++++++++++++++ .../uffs-cli/src/commands/daemon_tiering.rs | 2 +- .../src/commands/uninstall/coverage.rs | 157 +++++---- crates/uffs-cli/src/commands/uninstall/mod.rs | 279 +++++++++++---- .../uffs-cli/src/commands/uninstall/render.rs | 93 ++--- 7 files changed, 690 insertions(+), 418 deletions(-) create mode 100644 crates/uffs-cli/src/commands/daemon_status.rs diff --git a/crates/uffs-cli/src/commands.rs b/crates/uffs-cli/src/commands.rs index 8f99be43e..cb0f34c1d 100644 --- a/crates/uffs-cli/src/commands.rs +++ b/crates/uffs-cli/src/commands.rs @@ -16,6 +16,7 @@ pub mod aggregate; pub(crate) mod daemon_load; /// Daemon management subcommands. pub(crate) mod daemon_mgmt; +pub(crate) mod daemon_status; /// Memory-tiering operator commands (`hibernate` / `preload`). /// /// Phase 8-B / 8-C — split off `daemon_mgmt` so each cluster stays diff --git a/crates/uffs-cli/src/commands/daemon_mgmt.rs b/crates/uffs-cli/src/commands/daemon_mgmt.rs index 0f599ba3b..01013fb4e 100644 --- a/crates/uffs-cli/src/commands/daemon_mgmt.rs +++ b/crates/uffs-cli/src/commands/daemon_mgmt.rs @@ -1,15 +1,69 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! `uffs --daemon {status|stop|kill|restart}` subcommand handlers. +//! `uffs --daemon` subcommand dispatch + the mutating handlers +//! (start/stop/kill/restart) and their elevation gate. The read-only +//! status/stats displays live in the sibling +//! [`crate::commands::daemon_status`]. use anyhow::{Context as _, Result}; use uffs_client::connect_sync::UffsClientSync; use uffs_client::daemon_ctl::{pid_file_path, socket_path}; -use uffs_client::protocol::response::{DaemonStatus, DriveInfo, ShardTier}; +use uffs_client::protocol::response::DaemonStatus; use crate::args::DaemonAction; -use crate::commands::{daemon_load, daemon_tiering}; +use crate::commands::{daemon_load, daemon_status, daemon_tiering}; + +/// Suppress the user-facing progress prints of the daemon handlers while an +/// internal flow (the uninstall's background drive-coverage reload) runs them +/// behind a spinner. Read by the print sites in `daemon_start` / `daemon_kill`; +/// set only by [`daemon_quiet`] (RAII-reset, so it never sticks past that +/// call). +static QUIET: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); + +/// True while [`daemon_quiet`] is executing. +fn is_quiet() -> bool { + QUIET.load(core::sync::atomic::Ordering::Relaxed) +} + +/// RAII reset for the [`QUIET`] flag, so an early return or panic inside the +/// handler can never leave later daemon commands silenced. +#[cfg_attr( + not(windows), + expect( + dead_code, + reason = "constructed only by daemon_quiet, whose sole caller is the Windows uninstall coverage" + ) +)] +struct QuietGuard; + +impl Drop for QuietGuard { + fn drop(&mut self) { + QUIET.store(false, core::sync::atomic::Ordering::Relaxed); + } +} + +/// Run [`daemon`] with its user-facing progress prints suppressed — the same +/// handlers behind the same elevation gate, just silent. For internal flows +/// that reload the daemon in the background behind a spinner (the uninstall +/// deep-sweep coverage), where live "Starting daemon..." lines would garble an +/// interactive prompt on the main thread. +/// +/// # Errors +/// +/// Exactly [`daemon`]'s errors. +#[cfg_attr( + not(windows), + expect( + dead_code, + reason = "called only by the Windows uninstall deep-sweep coverage" + ) +)] +pub(crate) fn daemon_quiet(action: &DaemonAction) -> Result<()> { + QUIET.store(true, core::sync::atomic::Ordering::Relaxed); + let _guard = QuietGuard; + daemon(action) +} /// Execute a daemon management action. /// @@ -98,8 +152,8 @@ pub(crate) fn daemon(action: &DaemonAction) -> Result<()> { log_file.as_deref(), *elevate, ), - DaemonAction::Status => daemon_status(), - DaemonAction::Stats => daemon_stats(), + DaemonAction::Status => daemon_status::daemon_status(), + DaemonAction::Stats => daemon_status::daemon_stats(), DaemonAction::Stop => daemon_stop(), DaemonAction::Kill => { daemon_kill(); @@ -190,7 +244,9 @@ fn daemon_start( ) -> Result<()> { // Already running? if UffsClientSync::connect_raw().is_ok() { - println!("Daemon is already running. Use `uffs --daemon restart` to reload."); + if !is_quiet() { + println!("Daemon is already running. Use `uffs --daemon restart` to reload."); + } return Ok(()); } @@ -290,7 +346,9 @@ fn daemon_start( ); } - println!("Starting daemon..."); + if !is_quiet() { + println!("Starting daemon..."); + } // `--elevate` (or UFFS_ELEVATE=1) opts in to a UAC prompt on Windows // when the current shell is not elevated. The default path refuses @@ -307,250 +365,12 @@ fn daemon_start( .await_ready(core::time::Duration::from_mins(2)) .with_context(|| "Daemon did not become ready in time")?; - println!("Daemon started and ready."); - Ok(()) -} - -/// `uffs --daemon status` — show daemon status, PID, loaded drives. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn daemon_status() -> Result<()> { - let Ok(mut client) = UffsClientSync::connect_raw() else { - print_not_running(); - return Ok(()); - }; - - let Ok(status) = client.status() else { - print_not_running(); - return Ok(()); - }; - - let uptime = core::time::Duration::from_secs(status.uptime_secs); - println!( - "Version: {}", - crate::commands::version_summary(&status.version) - ); - println!("Daemon PID: {}", status.pid); - println!( - "Uptime: {}", - uffs_client::format::format_duration(uptime) - ); - match &status.status { - DaemonStatus::Loading { - drives_loaded, - drives_total, - } => { - println!("Status: Loading ({drives_loaded}/{drives_total} drives)"); - } - DaemonStatus::Ready => { - println!("Status: Ready"); - } - DaemonStatus::Refreshing { drives } => { - let drive_list: String = drives - .iter() - .map(|letter| format!("{letter}:")) - .collect::>() - .join(", "); - println!("Status: Refreshing ({drive_list})"); - } - } - println!("Connections: {}", status.connections); - - // Memory info. Three numbers, in increasing order of "what the OS - // sees": logical heap (sum of per-drive `heap_size_bytes`), then - // mimalloc's committed pages, then the OS-reported RSS. All three - // come from the same `status` payload so they are consistent. - if let Some(heap) = status.index_heap_bytes { - println!("Index heap: {} MB", heap / (1024 * 1024)); - } - if let Some(committed) = status.mimalloc_committed_bytes { - println!( - "Mimalloc: {} MB (committed)", - committed / (1024 * 1024) - ); - } - if let Some(rss) = status.rss_bytes { - println!("RSS: {} MB", rss / (1024 * 1024)); - } - - // Also show loaded drives. The `drives` RPC returns every shard - // in the registry — Warm/Hot with their full memory breakdown, - // Parked/Cold with just the tier marker (no body in RAM). Empty - // registry still renders `(none loaded)` so cold-boot detection in - // external scripts (api-validation, mcp-validation) keeps working. - let drives = client.drives().with_context(|| "Failed to query drives")?; - if drives.drives.is_empty() { - println!("Drives: (none loaded)"); - } else { - println!("Drives:"); - for dr in &drives.drives { - print_drive_line(dr, &status.drive_memory); - } + if !is_quiet() { + println!("Daemon started and ready."); } Ok(()) } -/// Render one row of the `Drives:` block in `daemon status`. -/// -/// Format depends on the shard's tier (per Phase 5 task 5.11): -/// * Warm/Hot — full breakdown (records count, source, memory rec= / names= / -/// tri= / ch= / ext=). -/// * Parked — `[Parked]` marker + bloom + trie kept resident note. -/// * Cold — `[Cold]` marker only (no body, no filters). -/// * Other — fall back to the legacy single-line format so the formatter -/// never panics on a state we haven't taught it about. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn print_drive_line( - dr: &DriveInfo, - drive_memory: &[uffs_client::protocol::response::DriveMemoryInfo], -) { - let tier_marker = tier_marker(dr.tier); - match dr.tier { - Some(ShardTier::Warm | ShardTier::Hot) | None => { - let mem = drive_memory.iter().find(|dm| dm.drive == dr.letter); - if let Some(dm) = mem { - let mb = |bytes: u64| bytes / (1024 * 1024); - println!( - " {} {}: — {:>10} records ({}) — {} MB [rec={} names={} tri={} ch={} ext={}]", - tier_marker, - dr.letter, - uffs_client::format::format_number_commas(dr.records as u64), - dr.source, - mb(dm.heap_bytes), - mb(dm.records_bytes), - mb(dm.names_bytes), - mb(dm.trigram_bytes), - mb(dm.children_bytes), - mb(dm.ext_index_bytes), - ); - } else { - println!( - " {} {}: — {:>10} records ({})", - tier_marker, - dr.letter, - uffs_client::format::format_number_commas(dr.records as u64), - dr.source - ); - } - } - Some(ShardTier::Parked) => { - println!( - " {} {}: — bloom + trie kept resident; body released", - tier_marker, dr.letter - ); - } - Some(ShardTier::Cold) => { - println!( - " {} {}: — encrypted cache only; nothing in RAM", - tier_marker, dr.letter - ); - } - Some(ShardTier::Evicting | ShardTier::Unknown) => { - println!(" {} {}: — ({})", tier_marker, dr.letter, dr.source); - } - } -} - -/// Format the bracket-style tier marker for `daemon status`'s drive -/// list. An 8-character right-padded label so the per-drive lines -/// align in the operator's terminal. -const fn tier_marker(tier: Option) -> &'static str { - match tier { - Some(ShardTier::Hot) => "[Hot] ", - Some(ShardTier::Warm) => "[Warm] ", - Some(ShardTier::Parked) => "[Parked]", - Some(ShardTier::Cold) => "[Cold] ", - Some(ShardTier::Evicting) => "[Evict] ", - Some(ShardTier::Unknown) => "[?] ", - None => " ", - } -} - -/// Print the "not running" message with optional stale-PID hint. -/// -/// Visible to sibling command modules (`daemon_tiering.rs`) so the -/// graceful "daemon down" rendering stays consistent across every -/// read-only daemon command — the operator sees the **same** stdout -/// shape from `uffs --daemon status` and `uffs --daemon status_drives` -/// when the daemon happens to be down. Mutating commands -/// (`hibernate` / `preload` / `forget`) deliberately stay on the -/// bail-with-error path because the operator should know their -/// requested mutation didn't run. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_not_running() { - println!("Daemon is not running."); - let pid_path = pid_file_path(); - if pid_path.exists() { - println!(" (stale PID file exists at {})", pid_path.display()); - } -} - -/// `uffs --daemon stats` — show performance metrics. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn daemon_stats() -> Result<()> { - if let Ok(mut client) = UffsClientSync::connect_raw() { - let stats = client - .stats() - .with_context(|| "Failed to query daemon stats")?; - - let fmt = uffs_client::format::format_duration; - let uptime = core::time::Duration::from_secs(stats.uptime_secs); - let startup = core::time::Duration::from_millis(stats.startup_duration_ms); - let avg_query = core::time::Duration::from_micros(uffs_client::format::f64_to_u64( - stats.avg_query_time_us, - )); - let total_query = core::time::Duration::from_micros(stats.total_query_time_us); - - println!("═══ Daemon Performance Stats ═══"); - println!( - "Version: {}", - crate::commands::version_summary(&stats.version) - ); - println!("Uptime: {}", fmt(uptime)); - println!("Startup duration: {}", fmt(startup)); - println!( - "Total records: {}", - uffs_client::format::format_number_commas(stats.total_records as u64) - ); - println!("Queries served: {}", stats.total_queries); - if stats.total_queries > 0 { - println!("Avg query time: {}", fmt(avg_query)); - println!("Total query time: {}", fmt(total_query)); - } - println!("Queries/second: {:.2}", stats.queries_per_second); - - // Aggregate cache observability. Hit-rate is computed on - // demand to avoid a division-by-zero for cold daemons. - let lookups = stats.agg_cache_hits.saturating_add(stats.agg_cache_misses); - let hit_rate = compute_hit_rate_percent(stats.agg_cache_hits, lookups); - println!( - "Agg cache: {} hits / {} misses ({:.1}% hit-rate, {} entries)", - stats.agg_cache_hits, stats.agg_cache_misses, hit_rate, stats.agg_cache_entries, - ); - } else { - println!("Daemon is not running."); - } - Ok(()) -} - -/// Compute aggregate-cache hit-rate as a percentage for daemon status display. -/// -/// Returns `0.0` when no lookups have occurred, avoiding a division by -/// zero on cold daemons. The `cast_precision_loss` expect is justified -/// for telemetry display: well over `2^53` cache lookups would be -/// required to lose a single bit of precision, and the output is -/// rendered with `{:.1}` so single-bit differences are invisible. -#[expect( - clippy::float_arithmetic, - clippy::cast_precision_loss, - reason = "telemetry hit-rate percent; rendered with `{:.1}` so precision loss is invisible" -)] -fn compute_hit_rate_percent(hits: u64, lookups: u64) -> f64 { - if lookups == 0 { - return 0.0_f64; - } - (hits as f64 / lookups as f64) * 100.0_f64 -} - /// `uffs --daemon stop` — graceful shutdown via RPC. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] fn daemon_stop() -> Result<()> { @@ -582,16 +402,18 @@ fn daemon_kill() { } if let Some(target_pid) = pid { - println!("Killing daemon (PID {target_pid})..."); + if !is_quiet() { + println!("Killing daemon (PID {target_pid})..."); + } kill_pid(target_pid); - } else { + } else if !is_quiet() { println!("No daemon found (no PID file, no socket connection)."); } // Always clean up stale files. drop(std::fs::remove_file(&pid_path)); drop(std::fs::remove_file(socket_path())); - if pid.is_some() { + if pid.is_some() && !is_quiet() { println!("Daemon killed. PID file and socket cleaned up."); } } diff --git a/crates/uffs-cli/src/commands/daemon_status.rs b/crates/uffs-cli/src/commands/daemon_status.rs new file mode 100644 index 000000000..d4f4fd2e5 --- /dev/null +++ b/crates/uffs-cli/src/commands/daemon_status.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `uffs --daemon status` / `uffs --daemon stats` — the read-only daemon +//! status and performance displays. +//! +//! Split out of `daemon_mgmt.rs` (which keeps the dispatch, elevation gate, +//! and the mutating start/stop/kill/restart handlers) so the pure rendering +//! concern lives beside its siblings `daemon_load.rs` / `daemon_tiering.rs`. + +use anyhow::{Context as _, Result}; +use uffs_client::connect_sync::UffsClientSync; +use uffs_client::daemon_ctl::pid_file_path; +use uffs_client::protocol::response::{DaemonStatus, DriveInfo, ShardTier}; + +/// `uffs --daemon status` — show daemon status, PID, loaded drives. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn daemon_status() -> Result<()> { + let Ok(mut client) = UffsClientSync::connect_raw() else { + print_not_running(); + return Ok(()); + }; + + let Ok(status) = client.status() else { + print_not_running(); + return Ok(()); + }; + + let uptime = core::time::Duration::from_secs(status.uptime_secs); + println!( + "Version: {}", + crate::commands::version_summary(&status.version) + ); + println!("Daemon PID: {}", status.pid); + println!( + "Uptime: {}", + uffs_client::format::format_duration(uptime) + ); + match &status.status { + DaemonStatus::Loading { + drives_loaded, + drives_total, + } => { + println!("Status: Loading ({drives_loaded}/{drives_total} drives)"); + } + DaemonStatus::Ready => { + println!("Status: Ready"); + } + DaemonStatus::Refreshing { drives } => { + let drive_list: String = drives + .iter() + .map(|letter| format!("{letter}:")) + .collect::>() + .join(", "); + println!("Status: Refreshing ({drive_list})"); + } + } + println!("Connections: {}", status.connections); + + // Memory info. Three numbers, in increasing order of "what the OS + // sees": logical heap (sum of per-drive `heap_size_bytes`), then + // mimalloc's committed pages, then the OS-reported RSS. All three + // come from the same `status` payload so they are consistent. + if let Some(heap) = status.index_heap_bytes { + println!("Index heap: {} MB", heap / (1024 * 1024)); + } + if let Some(committed) = status.mimalloc_committed_bytes { + println!( + "Mimalloc: {} MB (committed)", + committed / (1024 * 1024) + ); + } + if let Some(rss) = status.rss_bytes { + println!("RSS: {} MB", rss / (1024 * 1024)); + } + + // Also show loaded drives. The `drives` RPC returns every shard + // in the registry — Warm/Hot with their full memory breakdown, + // Parked/Cold with just the tier marker (no body in RAM). Empty + // registry still renders `(none loaded)` so cold-boot detection in + // external scripts (api-validation, mcp-validation) keeps working. + let drives = client.drives().with_context(|| "Failed to query drives")?; + if drives.drives.is_empty() { + println!("Drives: (none loaded)"); + } else { + println!("Drives:"); + for dr in &drives.drives { + print_drive_line(dr, &status.drive_memory); + } + } + Ok(()) +} + +/// Render one row of the `Drives:` block in `daemon status`. +/// +/// Format depends on the shard's tier (per Phase 5 task 5.11): +/// * Warm/Hot — full breakdown (records count, source, memory rec= / names= / +/// tri= / ch= / ext=). +/// * Parked — `[Parked]` marker + bloom + trie kept resident note. +/// * Cold — `[Cold]` marker only (no body, no filters). +/// * Other — fall back to the legacy single-line format so the formatter +/// never panics on a state we haven't taught it about. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn print_drive_line( + dr: &DriveInfo, + drive_memory: &[uffs_client::protocol::response::DriveMemoryInfo], +) { + let tier_marker = tier_marker(dr.tier); + match dr.tier { + Some(ShardTier::Warm | ShardTier::Hot) | None => { + let mem = drive_memory.iter().find(|dm| dm.drive == dr.letter); + if let Some(dm) = mem { + let mb = |bytes: u64| bytes / (1024 * 1024); + println!( + " {} {}: — {:>10} records ({}) — {} MB [rec={} names={} tri={} ch={} ext={}]", + tier_marker, + dr.letter, + uffs_client::format::format_number_commas(dr.records as u64), + dr.source, + mb(dm.heap_bytes), + mb(dm.records_bytes), + mb(dm.names_bytes), + mb(dm.trigram_bytes), + mb(dm.children_bytes), + mb(dm.ext_index_bytes), + ); + } else { + println!( + " {} {}: — {:>10} records ({})", + tier_marker, + dr.letter, + uffs_client::format::format_number_commas(dr.records as u64), + dr.source + ); + } + } + Some(ShardTier::Parked) => { + println!( + " {} {}: — bloom + trie kept resident; body released", + tier_marker, dr.letter + ); + } + Some(ShardTier::Cold) => { + println!( + " {} {}: — encrypted cache only; nothing in RAM", + tier_marker, dr.letter + ); + } + Some(ShardTier::Evicting | ShardTier::Unknown) => { + println!(" {} {}: — ({})", tier_marker, dr.letter, dr.source); + } + } +} + +/// Format the bracket-style tier marker for `daemon status`'s drive +/// list. An 8-character right-padded label so the per-drive lines +/// align in the operator's terminal. +const fn tier_marker(tier: Option) -> &'static str { + match tier { + Some(ShardTier::Hot) => "[Hot] ", + Some(ShardTier::Warm) => "[Warm] ", + Some(ShardTier::Parked) => "[Parked]", + Some(ShardTier::Cold) => "[Cold] ", + Some(ShardTier::Evicting) => "[Evict] ", + Some(ShardTier::Unknown) => "[?] ", + None => " ", + } +} + +/// Print the "not running" message with optional stale-PID hint. +/// +/// Visible to sibling command modules (`daemon_tiering.rs`) so the +/// graceful "daemon down" rendering stays consistent across every +/// read-only daemon command — the operator sees the **same** stdout +/// shape from `uffs --daemon status` and `uffs --daemon status_drives` +/// when the daemon happens to be down. Mutating commands +/// (`hibernate` / `preload` / `forget`) deliberately stay on the +/// bail-with-error path because the operator should know their +/// requested mutation didn't run. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn print_not_running() { + println!("Daemon is not running."); + let pid_path = pid_file_path(); + if pid_path.exists() { + println!(" (stale PID file exists at {})", pid_path.display()); + } +} + +/// `uffs --daemon stats` — show performance metrics. +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +pub(crate) fn daemon_stats() -> Result<()> { + if let Ok(mut client) = UffsClientSync::connect_raw() { + let stats = client + .stats() + .with_context(|| "Failed to query daemon stats")?; + + let fmt = uffs_client::format::format_duration; + let uptime = core::time::Duration::from_secs(stats.uptime_secs); + let startup = core::time::Duration::from_millis(stats.startup_duration_ms); + let avg_query = core::time::Duration::from_micros(uffs_client::format::f64_to_u64( + stats.avg_query_time_us, + )); + let total_query = core::time::Duration::from_micros(stats.total_query_time_us); + + println!("═══ Daemon Performance Stats ═══"); + println!( + "Version: {}", + crate::commands::version_summary(&stats.version) + ); + println!("Uptime: {}", fmt(uptime)); + println!("Startup duration: {}", fmt(startup)); + println!( + "Total records: {}", + uffs_client::format::format_number_commas(stats.total_records as u64) + ); + println!("Queries served: {}", stats.total_queries); + if stats.total_queries > 0 { + println!("Avg query time: {}", fmt(avg_query)); + println!("Total query time: {}", fmt(total_query)); + } + println!("Queries/second: {:.2}", stats.queries_per_second); + + // Aggregate cache observability. Hit-rate is computed on + // demand to avoid a division-by-zero for cold daemons. + let lookups = stats.agg_cache_hits.saturating_add(stats.agg_cache_misses); + let hit_rate = compute_hit_rate_percent(stats.agg_cache_hits, lookups); + println!( + "Agg cache: {} hits / {} misses ({:.1}% hit-rate, {} entries)", + stats.agg_cache_hits, stats.agg_cache_misses, hit_rate, stats.agg_cache_entries, + ); + } else { + println!("Daemon is not running."); + } + Ok(()) +} + +/// Compute aggregate-cache hit-rate as a percentage for daemon status display. +/// +/// Returns `0.0` when no lookups have occurred, avoiding a division by +/// zero on cold daemons. The `cast_precision_loss` expect is justified +/// for telemetry display: well over `2^53` cache lookups would be +/// required to lose a single bit of precision, and the output is +/// rendered with `{:.1}` so single-bit differences are invisible. +#[expect( + clippy::float_arithmetic, + clippy::cast_precision_loss, + reason = "telemetry hit-rate percent; rendered with `{:.1}` so precision loss is invisible" +)] +fn compute_hit_rate_percent(hits: u64, lookups: u64) -> f64 { + if lookups == 0 { + return 0.0_f64; + } + (hits as f64 / lookups as f64) * 100.0_f64 +} diff --git a/crates/uffs-cli/src/commands/daemon_tiering.rs b/crates/uffs-cli/src/commands/daemon_tiering.rs index cb5da3a6e..5c6c6c531 100644 --- a/crates/uffs-cli/src/commands/daemon_tiering.rs +++ b/crates/uffs-cli/src/commands/daemon_tiering.rs @@ -273,7 +273,7 @@ pub(crate) fn daemon_status_drives() -> Result<()> { // a misleading "daemon is not running" when the daemon is // actually up but speaking an older protocol. let Ok(mut client) = UffsClientSync::connect_raw() else { - crate::commands::daemon_mgmt::print_not_running(); + crate::commands::daemon_status::print_not_running(); return Ok(()); }; diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 5d4e28c17..43ead6fa4 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -7,18 +7,20 @@ //! it is only as complete as the set of drives the daemon has loaded. Before //! the sweep we make sure the daemon covers every NTFS drive; if it does not, //! we reload it cleanly — **kill then start** — by calling the exact same -//! handlers the CLI dispatches for `uffs --daemon kill` / `uffs --daemon start` -//! ([`daemon_mgmt::daemon`]), in-process. +//! handlers the CLI dispatches for `uffs --daemon kill` / `uffs --daemon +//! start`, in-process (the daemon spawns as a direct child, identical to a +//! shell start; an earlier subprocess relaunch made it a grandchild and was +//! abandoned). //! -//! Calling those handlers directly is the whole point: the daemon is then -//! spawned as a **direct child of this process**, identical to a shell -//! `uffs --daemon start`. An earlier attempt shelled out to -//! `uffs.exe --daemon start` as a subprocess, which made the daemon a -//! *grandchild* and intermittently hung its drive load (stuck at `5/7`). -//! Re-using the handler avoids that entirely. +//! Two narration modes: **loud** (`-v` sequential runs — everything prints +//! live, including the daemon handlers' own lines) and **quiet** (the default +//! background gather — the daemon handlers are silenced via +//! [`daemon_mgmt::daemon_quiet`] and the narration is *deferred*: collected as +//! note strings the caller prints with the final presentation, so nothing ever +//! garbles the interactive prompt on the main thread). //! -//! Windows-only: off Windows UFFS indexes offline MFT captures, not the live -//! filesystem, so there is no live drive coverage to ensure. +//! Best-effort throughout: any failure leaves coverage as-is and the sweep +//! proceeds against whatever is currently loaded. #![cfg(windows)] @@ -41,12 +43,14 @@ const POLL_INTERVAL: Duration = Duration::from_millis(500); /// Ensure the daemon covers every NTFS drive before the deep sweep. No-op when /// coverage is already complete; otherwise reload the daemon (kill + start) -/// via the real CLI handlers. Best-effort: any failure just means the sweep -/// covers whatever is currently loaded. -pub(crate) fn ensure_drive_coverage() { +/// via the real CLI handlers. Returns the deferred narration notes (always +/// empty in loud mode, where everything printed live). Best-effort: any +/// failure just means the sweep covers whatever is currently loaded. +pub(crate) fn ensure_drive_coverage(quiet: bool) -> Vec { + let mut notes: Vec = Vec::new(); let all = detect_ntfs_drives(); if all.is_empty() { - return; + return notes; } let managed = current_managed_drives(); let missing: Vec = all @@ -55,10 +59,11 @@ pub(crate) fn ensure_drive_coverage() { .copied() .collect(); if missing.is_empty() { - // The daemon already covers every system drive — proceed silently. - return; + // The daemon already covers every system drive — nothing to do. + return notes; } - reload_daemon_for_coverage(&all, &missing); + reload_daemon_for_coverage(&all, &missing, quiet, &mut notes); + notes } /// The drive letters the daemon currently manages (any tier). Empty when the @@ -78,29 +83,94 @@ fn managed_letters(client: &mut UffsClientSync) -> Vec { } /// Reload the daemon so it covers every drive: `kill`, wait for it to exit, -/// then `start`. Both steps go through [`daemon_mgmt::daemon`] — the exact -/// handlers `uffs --daemon kill` / `uffs --daemon start` use — so the daemon is -/// spawned in-process as a direct child (see module docs). -fn reload_daemon_for_coverage(all: &[DriveLetter], missing: &[DriveLetter]) { - print_reload_intro(missing, all.len()); - - if let Err(err) = daemon_mgmt::daemon(&DaemonAction::Kill) { - print_reload_failed("kill the daemon", &err); +/// then `start` (blocks until Ready = every drive loaded). Both steps go +/// through the real CLI handlers — silenced ones in quiet mode. +fn reload_daemon_for_coverage( + all: &[DriveLetter], + missing: &[DriveLetter], + quiet: bool, + notes: &mut Vec, +) { + let list = missing + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + if quiet { + notes.push(format!( + "\nNote: the index daemon was reloaded (kill + start) to cover every drive\n\ + for the deep sweep (it was missing {list})." + )); + } else { + emit( + quiet, + notes, + format!( + "\nDaemon is not indexing every drive (missing {list}; {covered} of {total} \ + covered).\nReloading it (kill + start) for a complete deep sweep:", + covered = all.len().saturating_sub(missing.len()), + total = all.len(), + ), + ); + } + + if let Err(err) = run_handler(quiet, &DaemonAction::Kill) { + emit( + quiet, + notes, + format!( + " could not kill the daemon: {err}. Continuing the deep sweep with whatever \ + is loaded." + ), + ); return; } wait_until_daemon_down(); - // `daemon start` blocks until the daemon is Ready (every drive loaded), so - // on success coverage is complete. - if let Err(err) = daemon_mgmt::daemon(&start_action()) { - print_reload_failed("start the daemon", &err); + if let Err(err) = run_handler(quiet, &start_action()) { + emit( + quiet, + notes, + format!( + " could not start the daemon: {err}. Continuing the deep sweep with whatever \ + is loaded." + ), + ); return; } let managed = current_managed_drives(); let covered = all.iter().filter(|drive| managed.contains(drive)).count(); if covered < all.len() { - print_partial_coverage_notice(covered, all.len()); + emit( + quiet, + notes, + format!( + " daemon covers {covered} of {total} drive(s); the deep sweep will scan those.", + total = all.len(), + ), + ); + } +} + +/// Dispatch `action` through the CLI handlers — the silenced variant in quiet +/// mode so background work never prints over the interactive prompt. +fn run_handler(quiet: bool, action: &DaemonAction) -> anyhow::Result<()> { + if quiet { + daemon_mgmt::daemon_quiet(action) + } else { + daemon_mgmt::daemon(action) + } +} + +/// Route one narration line: printed live in loud mode, deferred as a note in +/// quiet mode (the caller prints notes with the final presentation). +#[expect(clippy::print_stdout, reason = "CLI progress output (loud mode only)")] +fn emit(quiet: bool, notes: &mut Vec, line: String) { + if quiet { + notes.push(line); + } else { + println!("{line}"); } } @@ -129,30 +199,3 @@ fn wait_until_daemon_down() { std::thread::sleep(POLL_INTERVAL); } } - -/// Announce the kill+start because coverage is incomplete. -#[expect(clippy::print_stdout, reason = "CLI progress output")] -fn print_reload_intro(missing: &[DriveLetter], total: usize) { - let list = missing - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "); - println!( - "\nDaemon is not indexing every drive (missing {list}; {covered} of {total} covered).\n\ - Reloading it (kill + start) for a complete deep sweep:", - covered = total.saturating_sub(missing.len()), - ); -} - -/// Note that the reload could not complete; the sweep continues best-effort. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn print_reload_failed(what: &str, err: &anyhow::Error) { - println!(" could not {what}: {err}. Continuing the deep sweep with whatever is loaded."); -} - -/// Note that the daemon covers only some drives after the reload. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -fn print_partial_coverage_notice(covered: usize, total: usize) { - println!(" daemon covers {covered} of {total} drive(s); the deep sweep will scan those."); -} diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index e9152bfdd..091e20fb4 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -71,36 +71,41 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { render::print_run_header(); // `-v` also unlocks the deep-sweep diagnostics printed via - // [`sweep::dbg_line`] during the stray search below. + // [`sweep::dbg_line`] during the stray search. #[cfg(windows)] sweep::set_verbose(parsed.verbose); + // Overlap the slow work with the user's first decision: the drive-coverage + // reload + deep sweep start (quietly) in the background the moment the + // command fires, while the elevation question is on screen. `-v` runs + // sequentially instead so its live diagnostics stay readable. + #[cfg(windows)] + let gather = start_stray_gather(&parsed, &removal_plan); + let gate = elevation_gate(&parsed, &mut removal_plan)?; let skipped_elevation: Vec = match &gate { ElevationChoice::ContinueWithout(items) => items.clone(), ElevationChoice::NotNeeded | ElevationChoice::ElevateAtRemoval => Vec::new(), }; - // Scan overview: a one-line summary by default; the full binary resolution - // table + artifact inventory under `-v`. - if parsed.verbose { - render::print_resolution_table(&resolved); - render::print_inventory(&inventory); - } else { - render::print_scan_summary(&resolved, &inventory); - } + // Wait for the gather (spinner) / run it now (`-v`), then present the + // COMPLETE picture at once: CORE table + inventory, EXTRA table, the action + // plan, and the gate notes. Nothing was shown while data was in flight. + #[cfg(windows)] + let gathered = finish_stray_gather(&parsed, &removal_plan, gather); + #[cfg(windows)] + let stray_plan = &gathered.stray_plan; + #[cfg(not(windows))] + let no_strays = RemovalPlan::default(); + #[cfg(not(windows))] + let stray_plan = &no_strays; - // M7 deep sweep: ask UFFS itself for stray family files elsewhere on the - // live drives, version them, and build a separate plan removed only under - // its own confirmation (one may be a copy the user placed themselves). This - // is Windows-only — off Windows UFFS indexes offline captures, not the live - // filesystem, so PATH/standard-location copies (already folded into the main - // plan above) are all we can find. - let stray_plan = platform_stray_plan(&parsed, &removal_plan); - - // The FINAL summary — everything is gathered, so say exactly what this run - // will (and will not) do, then ask. The stray list printed just above by - // the sweep is part of this picture. + #[cfg(windows)] + render::print_coverage_notes(&gathered.coverage_notes); + render::print_resolution_table(&resolved); + render::print_inventory(&inventory); + #[cfg(windows)] + render::print_extra_table(&gathered.strays); render::print_plan(&removal_plan); render::print_skipped_elevation(&skipped_elevation); if matches!(gate, ElevationChoice::ElevateAtRemoval) { @@ -121,25 +126,35 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } - // Gather every decision UP FRONT, then execute once — never ask after - // removal has started. The broker keep/skip was decided at the elevation - // gate above. On Windows, decide the deep-sweep strays here too (a separate - // opt-in: a copy you placed yourself may be among them). - #[cfg(windows)] - let remove_strays = !stray_plan.is_empty() - && (parsed.assume_yes - || confirm(&format!( - "\nAlso remove the {} file(s) found elsewhere (listed above)? [y/N] ", - stray_plan.item_count() - ))?); - - // M4 consent (U-21): the final go. Declining aborts the whole uninstall. - if !removal_plan.is_empty() && !parsed.assume_yes && !confirm("\nProceed with removal? [y/N] ")? - { + // The single end-of-flow decision (design: decide -> gather -> present -> + // confirm). Every choice was collected before anything is touched. + let choice = final_consent(&parsed, &removal_plan, stray_plan)?; + if matches!(choice, FinalChoice::Abort) { print_aborted(); return Ok(()); } + let remove_strays = matches!(choice, FinalChoice::All) && !stray_plan.is_empty(); + execute_all( + &removal_plan, + stray_plan, + remove_strays, + matches!(gate, ElevationChoice::ElevateAtRemoval), + ); + Ok(()) +} +/// M4/M8/M9 execution: journal the run, execute the consented plan(s) once +/// against the live effects sink, print the outcome, schedule the deferred +/// self-delete, and verify the targeted locations are gone. Runs only after +/// [`final_consent`] — no questions are asked past this point, and every +/// failure is reported (never propagated: the run always finishes its +/// best-effort pass). +fn execute_all( + removal_plan: &RemovalPlan, + stray_plan: &RemovalPlan, + remove_strays: bool, + elevate_via_uac: bool, +) { // 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. @@ -155,22 +170,17 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // M4 execute (U-40..42): run the plan(s) once against the live effects sink, // accumulating a single outcome so the summary + retry hint print once. - let mut effects = effects::SystemEffects::new( - self_paths.clone(), - matches!(gate, ElevationChoice::ElevateAtRemoval), - ); + let mut effects = effects::SystemEffects::new(self_paths.clone(), elevate_via_uac); let mut outcome = remove::RemovalOutcome::default(); if !removal_plan.is_empty() { - outcome.absorb(remove::execute(&removal_plan, &mut effects)); + outcome.absorb(remove::execute(removal_plan, &mut effects)); } - #[cfg(windows)] if remove_strays { - outcome.absorb(remove::execute(&stray_plan, &mut effects)); + outcome.absorb(remove::execute(stray_plan, &mut effects)); } if !outcome.is_empty() { render::print_outcome(&outcome); } - #[cfg(windows)] if !stray_plan.is_empty() && !remove_strays { render::print_strays_kept(); } @@ -186,7 +196,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // 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) + let to_check: Vec = plan_dirs(removal_plan) .into_iter() .filter(|dir| { !self_paths @@ -200,7 +210,6 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { if let Err(err) = journal::finish() { render::print_journal_warning(&err); } - Ok(()) } /// M1+M2 analysis (read-only, no output): reuse the self-update Phase-A @@ -310,7 +319,6 @@ fn platform_elevation_choice(removal_plan: &mut RemovalPlan) -> Result Result { use std::io::Write as _; @@ -376,28 +384,59 @@ fn plan_dirs(plan: &RemovalPlan) -> Vec { .collect() } -/// Build the deep-sweep stray plan for the current platform. +/// Everything the deep-sweep gather produces for the final presentation. +/// Windows-only — off Windows the daemon indexes offline captures, not the +/// live filesystem, so there is no stray phase at all. +#[cfg(windows)] +#[derive(Default)] +struct GatherOutcome { + /// The stray-removal plan (the EXTRA section), removed only on ALL. + stray_plan: RemovalPlan, + /// The stray hits behind that plan, for the EXTRA table. + strays: Vec, + /// Deferred coverage narration from the quiet background mode. + coverage_notes: Vec, +} + +/// Which stage the background gather is in, for the spinner label: +/// 0 = drive coverage (indexing), 1 = searching the index. +#[cfg(windows)] +static GATHER_PHASE: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(0); + +/// Start the drive-coverage check/reload + deep sweep on a background thread +/// the moment the uninstall fires, so the daemon index work overlaps the +/// elevation question instead of costing wall-clock time after it. Quiet: all +/// narration is deferred into the returned [`GatherOutcome`]. `None` when the +/// sweep is disabled (`--no-deep-sweep`) or running sequentially (`-v`). /// -/// Windows: ensure the daemon covers every NTFS drive (offering to start it / -/// index the missing drives), then ask UFFS for stray copies outside the known -/// roots and present them for a separate confirmation. The coverage offer runs -/// under `--dry-run` too — starting the daemon and indexing drives are -/// non-destructive, and a dry run should preview the *complete* picture; only -/// the deletions themselves are withheld (the caller returns before executing). +/// The known-dirs snapshot is taken before the elevation gate mutates the +/// plan; that is safe because the gate only drops service/process items, which +/// never contribute directories. #[cfg(windows)] -fn platform_stray_plan(parsed: &UninstallArgs, removal_plan: &RemovalPlan) -> RemovalPlan { - if parsed.no_deep_sweep { - return RemovalPlan::default(); +fn start_stray_gather( + parsed: &UninstallArgs, + removal_plan: &RemovalPlan, +) -> Option> { + if parsed.no_deep_sweep || parsed.verbose { + return None; } - // Indexing every drive is a non-elevated, non-destructive read the sweep - // needs, so it always runs (no prompt) — including under --dry-run, to make - // the preview accurate. - coverage::ensure_drive_coverage(); + GATHER_PHASE.store(0, core::sync::atomic::Ordering::Relaxed); let known = plan_dirs(removal_plan); - let mut search = sweep::DaemonSearch; + Some(std::thread::spawn(move || gather_strays(&known, true))) +} +/// The gather body: ensure drive coverage (quiet = narration deferred), then +/// search the live index for stray family files and build their plan. Runs +/// under `--dry-run` too — coverage and searching are non-destructive, and a +/// dry run should preview the *complete* picture. +#[cfg(windows)] +fn gather_strays(known: &[PathBuf], quiet: bool) -> GatherOutcome { + let coverage_notes = coverage::ensure_drive_coverage(quiet); + GATHER_PHASE.store(1, core::sync::atomic::Ordering::Relaxed); + + let mut search = sweep::DaemonSearch; let find_started = std::time::Instant::now(); - let candidates = sweep::find_strays(&mut search, &known).unwrap_or_default(); + let candidates = sweep::find_strays(&mut search, known).unwrap_or_default(); sweep::dbg_line(&format!( "found {} candidate file(s) in {:.2?} (after filtering)", candidates.len(), @@ -412,18 +451,114 @@ fn platform_stray_plan(parsed: &UninstallArgs, removal_plan: &RemovalPlan) -> Re probe_started.elapsed() )); - render::print_strays(&strays); - plan::build_stray_plan(&strays) + let stray_plan = plan::build_stray_plan(&strays); + GatherOutcome { + stray_plan, + strays, + coverage_notes, + } } -/// Build the deep-sweep stray plan for the current platform. -/// -/// Off Windows the daemon indexes offline captures, not the live filesystem, so -/// it cannot find local stray binaries; PATH/standard-location copies are -/// already folded into the main plan, leaving no separate stray phase. -#[cfg(not(windows))] -fn platform_stray_plan(_parsed: &UninstallArgs, _removal_plan: &RemovalPlan) -> RemovalPlan { - RemovalPlan::default() +/// Collect the gather results: join the background thread behind a spinner +/// (default), run the gather synchronously and loudly (`-v`), or return empty +/// (`--no-deep-sweep`). A panicked gather degrades to "no strays found". +#[cfg(windows)] +fn finish_stray_gather( + parsed: &UninstallArgs, + removal_plan: &RemovalPlan, + gather: Option>, +) -> GatherOutcome { + if let Some(handle) = gather { + spinner_wait(&handle); + return handle.join().unwrap_or_default(); + } + if parsed.no_deep_sweep { + return GatherOutcome::default(); + } + gather_strays(&plan_dirs(removal_plan), false) +} + +/// Animate a small spinner on the current line until `handle` finishes, with a +/// label tracking the gather stage; the line is cleared before returning so +/// the presentation starts clean. +#[cfg(windows)] +#[expect(clippy::print_stdout, reason = "interactive progress spinner")] +fn spinner_wait(handle: &std::thread::JoinHandle) { + use std::io::Write as _; + + const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let mut frame = 0_usize; + while !handle.is_finished() { + let label = if GATHER_PHASE.load(core::sync::atomic::Ordering::Relaxed) == 0 { + "indexing the drives for the deep sweep" + } else { + "searching the drives for UFFS files" + }; + let glyph = FRAMES.get(frame % FRAMES.len()).copied().unwrap_or("*"); + print!("\r{glyph} Gathering artifacts ({label})... "); + let _flushed = std::io::stdout().flush(); + std::thread::sleep(core::time::Duration::from_millis(120)); + frame = frame.wrapping_add(1); + } + print!("\r{:74}\r", ""); + let _flushed = std::io::stdout().flush(); +} + +/// The single end-of-flow decision (design: decide -> gather -> present -> +/// confirm), asked only once the complete picture is on screen. +enum FinalChoice { + /// Remove everything: the CORE install and the EXTRA files found elsewhere. + All, + /// Remove the CORE install only; leave the EXTRA files in place. + CoreOnly, + /// Remove nothing. + Abort, +} + +/// Ask the final consent question. With EXTRA files present this is a 3-way +/// ALL / CORE / ABORT tied to the section names above; without them it stays +/// the classic proceed-yes/no. `--yes` means ALL (the pre-existing semantics: +/// a scripted uninstall removes everything it found). +fn final_consent( + parsed: &UninstallArgs, + removal_plan: &RemovalPlan, + stray_plan: &RemovalPlan, +) -> Result { + if parsed.assume_yes { + return Ok(FinalChoice::All); + } + if stray_plan.is_empty() { + return Ok(if confirm("\nProceed with removal? [y/N] ")? { + FinalChoice::All + } else { + FinalChoice::Abort + }); + } + if removal_plan.is_empty() { + return Ok( + if confirm(&format!( + "\nRemove the {} EXTRA file(s) found elsewhere? [y/N] ", + stray_plan.item_count() + ))? { + FinalChoice::All + } else { + FinalChoice::Abort + }, + ); + } + let choice = prompt_choice(&format!( + "\nRemove:\n\ + \x20 a = ALL — CORE and the {n} EXTRA file(s) found elsewhere\n\ + \x20 c = CORE — the standard install only (leave the EXTRA files)\n\ + \x20 q = ABORT — nothing is removed\n\ + Choice [a/c/Q]: ", + n = stray_plan.item_count() + ))?; + Ok(match choice.as_str() { + "a" | "all" => FinalChoice::All, + "c" | "core" => FinalChoice::CoreOnly, + _ => FinalChoice::Abort, + }) } /// Prompt for a yes/no confirmation. Default (empty / anything but `y`/`yes`) diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 0cce02755..6b57d4db5 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -107,9 +107,9 @@ pub(crate) fn print_resolution_table(stems: &[StemResolution]) { let w_source = width("SOURCE", |row| row.source.len()); println!( - "Discovered UFFS binaries. STATUS: 'runs' = the copy a bare command executes \ - (first on PATH); 'shadowed' = on PATH but another runs first; 'off PATH' = \ - present but not on PATH.\n" + "\nCORE — the UFFS install (binaries, data, caches). STATUS: 'runs' = the copy a\n\ + bare command executes (first on PATH); 'shadowed' = on PATH but another runs\n\ + first; 'off PATH' = present but not on PATH.\n" ); // One printer for the header and every row, so the columns share widths and // there are no bare format literals. @@ -229,29 +229,6 @@ pub(crate) fn print_uac_note() { ); } -/// One-line scan overview printed by default in place of the full resolution -/// table + inventory (which move behind `-v`): how much was found, and where. -#[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_scan_summary(stems: &[StemResolution], inventory: &Inventory) { - let copies: usize = stems.iter().map(|stem| stem.copies.len()).sum(); - let mut dirs: Vec = stems - .iter() - .flat_map(|stem| { - stem.copies - .iter() - .map(|copy| copy.dir.to_string_lossy().to_ascii_lowercase()) - }) - .collect(); - dirs.sort_unstable(); - dirs.dedup(); - let data_dirs = inventory.dirs.iter().filter(|dir| dir.exists).count(); - println!( - "\nFound {copies} UFFS binaries in {} location(s) and {data_dirs} data/cache \ - location(s). (-v for the full inventory)", - dirs.len(), - ); -} - /// Dry-run note shown when the plan carries admin-only items but this terminal /// is not elevated: a real run will offer to skip them. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] @@ -262,31 +239,71 @@ pub(crate) fn print_dry_run_elevation_note() { ); } -/// Print stray UFFS files the deep sweep found outside the known roots, with -/// versions. These are removed only under a separate second confirmation (a -/// copy the user placed themselves might be among them). Windows-only. +/// Print the EXTRA section: stray UFFS files the deep sweep found outside the +/// standard install locations, as an aligned BINARY / VERSION / LOCATION table +/// (matching the CORE table's shape). Removed only when the final choice is +/// ALL — one may be a copy the user placed themselves. Windows-only. #[cfg(windows)] #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_strays(strays: &[StrayHit]) { +pub(crate) fn print_extra_table(strays: &[StrayHit]) { if strays.is_empty() { return; } + let rows: Vec<(String, String, String)> = strays + .iter() + .map(|stray| { + let binary = stray.path.file_name().map_or_else( + || stray.path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + let location = stray + .path + .parent() + .map_or_else(String::new, |dir| dir.display().to_string()); + let version = stray.version.clone().unwrap_or_else(|| "legacy".to_owned()); + (binary, version, location) + }) + .collect(); + let width = |header: &str, cell: fn(&(String, String, String)) -> usize| { + rows.iter() + .map(cell) + .chain(core::iter::once(header.len())) + .max() + .unwrap_or(0) + }; + let w_bin = width("BINARY", |row| row.0.len()); + let w_ver = width("VERSION", |row| row.1.len()); + println!( - "\nAlso found elsewhere (deep sweep), outside the standard install locations.\n\ - These are removed only if you confirm a separate prompt below (one may be a\n\ - copy you placed yourself):\n" + "\nEXTRA — UFFS files found elsewhere by the deep sweep (removed only with ALL;\n\ + one may be a copy you placed yourself):\n" ); - for stray in strays { - let version = stray.version.as_deref().unwrap_or("legacy"); - println!(" {version:<9} {}", stray.path.display()); + let print_row = |binary: &str, version: &str, location: &str| { + println!(" {binary: Date: Wed, 1 Jul 2026 22:27:04 -0700 Subject: [PATCH 29/40] fix(uninstall): presentation order + EXTRA in the plan summary; quiet the [diag] leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the live Windows dry-run of the new flow: - Presentation order: the data/cache/config inventory + broker-service state now print right after the coverage note, BEFORE the CORE binary table (was sandwiched between CORE and EXTRA). - The removal-plan summary now includes the deep-sweep findings instead of silently omitting them: a "Found elsewhere (EXTRA)" group line ("N UFFS file(s) outside the standard install locations (listed above; removed only with ALL)") and a reclaim line that reads "~X across N CORE item(s), plus M EXTRA file(s) with ALL" — alluding to the ALL/CORE/ABORT question the real run asks. "Nothing to remove" now only prints when BOTH plans are empty. - The daemon_start [diag] spawn-chain dump is also silenced in quiet mode: with UFFS_LOG=debug set it printed from the background reload straight over the spinner. - Dry-run keeps its established gate behavior (no elevation question; markers + the explanatory note), confirmed as the intended design. Validated with cargo clippy (host) + cargo xwin clippy (Windows); 44 unit tests pass. Co-Authored-By: Claude Fable 5 --- crates/uffs-cli/src/commands/daemon_mgmt.rs | 6 ++-- crates/uffs-cli/src/commands/uninstall/mod.rs | 11 ++++--- .../uffs-cli/src/commands/uninstall/render.rs | 33 +++++++++++++++---- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/crates/uffs-cli/src/commands/daemon_mgmt.rs b/crates/uffs-cli/src/commands/daemon_mgmt.rs index 01013fb4e..76cd26814 100644 --- a/crates/uffs-cli/src/commands/daemon_mgmt.rs +++ b/crates/uffs-cli/src/commands/daemon_mgmt.rs @@ -326,8 +326,10 @@ fn daemon_start( // Gated behind an explicit debug/trace log level: on the default // `daemon start` happy path users see clean output, not internals // (2026-06-12 fresh-VM dry run flagged the unconditional version as - // looking like leftover debug logging). - if matches!(effective_log_level.as_str(), "debug" | "trace") { + // looking like leftover debug logging). Also silenced in quiet mode — + // a background daemon reload must never print over an interactive + // prompt or spinner (observed with UFFS_LOG=debug set). + if matches!(effective_log_level.as_str(), "debug" | "trace") && !is_quiet() { println!( "[diag] daemon_start: drives={drives:?} log_level={log_level:?} log_file={log_file:?}" ); diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 091e20fb4..677a5b7f1 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -102,11 +102,11 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { #[cfg(windows)] render::print_coverage_notes(&gathered.coverage_notes); - render::print_resolution_table(&resolved); render::print_inventory(&inventory); + render::print_resolution_table(&resolved); #[cfg(windows)] render::print_extra_table(&gathered.strays); - render::print_plan(&removal_plan); + render::print_plan(&removal_plan, stray_plan); render::print_skipped_elevation(&skipped_elevation); if matches!(gate, ElevationChoice::ElevateAtRemoval) { render::print_uac_note(); @@ -259,9 +259,10 @@ enum ElevationChoice { /// non-elevated run is told immediately what needs Administrator and decides /// once — elevate at removal time (Windows: one UAC prompt), continue without /// (items dropped so the final summary never lists work that will not happen), -/// or abort. Skipped when elevated, under `--dry-run` (preview keeps the -/// markers), or when nothing needs Administrator. `--yes` continues without -/// asking — a scripted run must never trigger a surprise UAC prompt. +/// or abort. Skipped when elevated, under `--dry-run` (the preview keeps the +/// "needs Administrator" markers and notes that a real run asks), or when +/// nothing needs Administrator. `--yes` continues without asking — a scripted +/// run must never trigger a surprise UAC prompt. /// `uffs_mft::platform::is_elevated` is cross-platform (Windows token check; /// Unix effective-uid 0). fn elevation_gate( diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 6b57d4db5..61efbb360 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -157,8 +157,8 @@ pub(crate) fn print_inventory(inventory: &Inventory) { /// 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() { +pub(crate) fn print_plan(plan: &RemovalPlan, extra: &RemovalPlan) { + if plan.is_empty() && extra.is_empty() { println!("\nNothing to remove: no UFFS install or artifacts were found."); return; } @@ -179,11 +179,30 @@ pub(crate) fn print_plan(plan: &RemovalPlan) { index = index.saturating_add(1); } } - println!( - "\nReclaims ~{} across {} item(s).", - human_bytes(plan.total_bytes()), - plan.item_count(), - ); + // The EXTRA files ride the same summary so nothing is hidden from the + // final picture, but they are a separate choice: the ALL/CORE question. + if !extra.is_empty() { + println!("\n Found elsewhere (EXTRA)"); + println!( + " [{index}] {count} UFFS file(s) outside the standard install locations \ + (listed above; removed only with ALL)", + count = extra.item_count(), + ); + } + if extra.is_empty() { + println!( + "\nReclaims ~{} across {} item(s).", + human_bytes(plan.total_bytes()), + plan.item_count(), + ); + } else { + println!( + "\nReclaims ~{} across {} CORE item(s), plus {} EXTRA file(s) with ALL.", + human_bytes(plan.total_bytes()), + plan.item_count(), + extra.item_count(), + ); + } } /// The up-front elevation gate (U-30): the FIRST thing a non-elevated run says. From 99d95af00313a68e2b249920d4118f0ce380a812 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:09:21 -0700 Subject: [PATCH 30/40] style(uninstall): breathing room before the gate list and both Choice prompts Blank line between the elevation gate's intro and its item list, and before the "Choice [e/c/A]:" and "Choice [a/c/Q]:" lines, so the questions stand apart from their option lists. Co-Authored-By: Claude Fable 5 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 2 ++ crates/uffs-cli/src/commands/uninstall/render.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 677a5b7f1..267bd963a 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -290,6 +290,7 @@ fn platform_elevation_choice(removal_plan: &mut RemovalPlan) -> Result Date: Thu, 2 Jul 2026 06:47:44 -0700 Subject: [PATCH 31/40] fix(uninstall): teardown-last execution order + 5 live-run bugs from LOG/Output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real Windows run surfaced a cluster of execution bugs. All fixed at the root: 1. Teardown-last plan order (the user-prescribed sequence): tool binaries -> PATH -> "Shutdown (stopped last)" (daemon stop + broker service removal) -> data/cache/config (a running daemon holds handles inside them) -> "Runtime binaries (after shutdown)" (uffsd/uffs-broker/uffsmcp/uffs-mcp-http, whose images are locked while running) -> deferred self-delete of uffs.exe + uffs-update.exe. The working tools stay usable for the whole run. 2. One locked file no longer traps a whole directory: delete_binaries is now best-effort across the set (the original run lost 21 deletions to one lingering uffsd.exe), with a 750ms settle-and-retry pass, reporting exactly which files failed. 3. The daemon stop re-discovers the CURRENT daemon via the real `uffs --daemon kill` handler (pid file/socket) instead of the analyzed pid (stale after the deep sweep's coverage reload), then waits for IPC-down + image release before the runtime binaries are deleted. 4. Windows daemon-management elevation gate now mirrors the Unix owner gate: no elevation needed when there is no PID file (nothing to protect) or when the daemon's launch-state sidecar records a NON-elevated launch (the daemon now writes an "elevated" flag into daemon.state.json) — a user-level daemon is the user's to kill even with the broker gone. Falls back to the broker probe otherwise. 5. The deferred self-delete never actually deleted anything: std's Windows arg quoting backslash-escaped the `del "path"` quotes inside the `cmd /c` payload, which cmd.exe does not parse. Passed verbatim via raw_arg now. 6. Formatting: the coverage failure notes put "Continuing the deep sweep..." on its own line, and a blank separator precedes the `[sweep]` block (-v). 7. `uffs-broker --install` narrates its steps (sc create -> ok, sc start with a "can take a minute" note -> ok) instead of a silent minute-long wait. plan.rs crossed the 800-LOC budget with the reorder; fixed by decomposing (the established sibling-tests pattern): its unit tests moved to plan/tests.rs, leaving plan.rs at 513 LOC. Tests updated to the new order contract plus a regression test pinning the runtime-stem split. Validated with cargo clippy (host) + cargo xwin clippy (Windows, prod + tests); 38 uninstall tests pass. Co-Authored-By: Claude Fable 5 --- crates/uffs-broker/src/broker/service.rs | 27 + crates/uffs-cli/src/commands/daemon_mgmt.rs | 57 +- .../src/commands/uninstall/coverage.rs | 8 +- .../src/commands/uninstall/effects.rs | 76 ++- crates/uffs-cli/src/commands/uninstall/mod.rs | 1 + .../uffs-cli/src/commands/uninstall/plan.rs | 536 +++++------------- .../src/commands/uninstall/plan/tests.rs | 406 +++++++++++++ .../uffs-cli/src/commands/uninstall/remove.rs | 6 +- .../uffs-cli/src/commands/uninstall/sweep.rs | 9 + crates/uffs-daemon/src/lifecycle.rs | 4 + 10 files changed, 694 insertions(+), 436 deletions(-) create mode 100644 crates/uffs-cli/src/commands/uninstall/plan/tests.rs diff --git a/crates/uffs-broker/src/broker/service.rs b/crates/uffs-broker/src/broker/service.rs index cd4f21899..dc2c62679 100644 --- a/crates/uffs-broker/src/broker/service.rs +++ b/crates/uffs-broker/src/broker/service.rs @@ -52,6 +52,21 @@ fn sc_output(output: &std::process::Output) -> String { .to_owned() } +/// Print an in-progress step label without a trailing newline and flush, so +/// the operator sees what a slow step (e.g. the blocking `sc start`) is doing +/// before its "ok"/"failed" verdict lands on the same line. +#[cfg(windows)] +#[expect( + clippy::print_stdout, + reason = "CLI admin command — stdout is the user-visible result channel" +)] +fn print_step(label: &str) { + use std::io::Write as _; + + print!("{label}"); + let _flushed = std::io::stdout().flush(); +} + /// Register the broker as an auto-start Windows Service and start it. /// /// # Why the argv is split the way it is @@ -79,7 +94,11 @@ pub(super) fn install_service() -> anyhow::Result<()> { ); } + // Step-by-step narration: `sc start` blocks until the service reports + // ready, which can take a minute — a silent wait reads as a hang. let exe = std::env::current_exe()?; + println!("Installing the UFFS Access Broker service..."); + print_step(" registering the service (sc create)... "); let create = std::process::Command::new("sc.exe") .args([ "create", @@ -94,6 +113,7 @@ pub(super) fn install_service() -> anyhow::Result<()> { .output()?; if !create.status.success() { + println!("failed"); // AUDIT-OK(bytes): `sc` output surfaced verbatim to the operator — // display only, no decision. anyhow::bail!( @@ -102,21 +122,28 @@ pub(super) fn install_service() -> anyhow::Result<()> { sc_output(&create) ); } + println!("ok"); // Start it now so the broker is usable immediately — the whole point // is "no future UAC", which only holds once the service is running. // `start= auto` also brings it back on every boot. + print_step( + " starting the service (Windows waits for it to report ready; \ + this can take a minute)... ", + ); let start = std::process::Command::new("sc.exe") .args(["start", SERVICE_NAME]) .output()?; if start.status.success() { + println!("ok"); println!( "UFFS Access Broker installed and started (auto-start on boot).\n\ Non-elevated `uffs` searches will now use the broker for volume \ access — no more UAC prompts." ); } else { + println!("failed"); // AUDIT-OK(bytes): `sc` output surfaced verbatim to the operator. println!( "Service installed (auto-start on boot), but starting it failed: \ diff --git a/crates/uffs-cli/src/commands/daemon_mgmt.rs b/crates/uffs-cli/src/commands/daemon_mgmt.rs index 76cd26814..975323e29 100644 --- a/crates/uffs-cli/src/commands/daemon_mgmt.rs +++ b/crates/uffs-cli/src/commands/daemon_mgmt.rs @@ -28,13 +28,6 @@ fn is_quiet() -> bool { /// RAII reset for the [`QUIET`] flag, so an early return or panic inside the /// handler can never leave later daemon commands silenced. -#[cfg_attr( - not(windows), - expect( - dead_code, - reason = "constructed only by daemon_quiet, whose sole caller is the Windows uninstall coverage" - ) -)] struct QuietGuard; impl Drop for QuietGuard { @@ -52,13 +45,6 @@ impl Drop for QuietGuard { /// # Errors /// /// Exactly [`daemon`]'s errors. -#[cfg_attr( - not(windows), - expect( - dead_code, - reason = "called only by the Windows uninstall deep-sweep coverage" - ) -)] pub(crate) fn daemon_quiet(action: &DaemonAction) -> Result<()> { QUIET.store(true, core::sync::atomic::Ordering::Relaxed); let _guard = QuietGuard; @@ -207,18 +193,51 @@ fn daemon_owner_needs_elevation(pid_file: &std::path::Path, caller_euid: u32) -> std::fs::metadata(pid_file).is_ok_and(|meta| meta.uid() != caller_euid) } -/// Windows: elevation is required only when the Access Broker pipe is NOT -/// serving. With the broker up the daemon runs non-elevated and a non-elevated -/// caller can stop AND restart it (restart adopts broker handles — no UAC), so -/// a non-elevated `uffs --update` can quiesce/restart it; without the broker a -/// restart needs admin for the MFT (mirrors the Unix PID-owner gate). +/// Windows: mirror the Unix PID-owner gate as closely as the platform allows. +/// No elevation is needed when (in order): +/// +/// 1. **No daemon to protect** — the PID file is absent, so stop/kill/restart +/// cannot break anything a non-elevated caller could not bring back. +/// 2. **The daemon itself runs non-elevated** — its launch-state sidecar +/// (`daemon.state.json`, written into the *caller's own* `%LOCALAPPDATA%`, +/// so it is this user's daemon by construction) records `"elevated": false`; +/// a same-user, non-elevated process is killable and restartable without +/// admin. +/// 3. **The Access Broker pipe is serving** — a restart adopts broker handles, +/// so a non-elevated caller can stop AND bring the daemon back (no UAC). +/// +/// Otherwise (an elevated daemon, no broker) managing it needs Administrator. #[cfg(windows)] fn mutating_management_needs_elevation() -> bool { /// Short pipe probe — this gate runs once per management command. const BROKER_GATE_PROBE_MS: u32 = 600; + + let pid_path = pid_file_path(); + if !pid_path.exists() { + return false; + } + if launch_state_says_non_elevated(&pid_path) { + return false; + } !uffs_winsvc::pipe_serving(uffs_broker_protocol::PIPE_NAME, BROKER_GATE_PROBE_MS) } +/// Whether the daemon's launch-state sidecar (next to the PID file) records a +/// **non-elevated** launch. Absent file, unreadable JSON, or a pre-flag state +/// file all return `false` — the gate then falls back to the broker probe +/// (conservative: never *grants* user-level management on missing evidence). +#[cfg(windows)] +fn launch_state_says_non_elevated(pid_path: &std::path::Path) -> bool { + let state_path = pid_path.with_file_name("daemon.state.json"); + let Ok(raw) = std::fs::read_to_string(&state_path) else { + return false; + }; + serde_json::from_str::(&raw) + .ok() + .and_then(|state| state.get("elevated").and_then(serde_json::Value::as_bool)) + .is_some_and(|elevated| !elevated) +} + /// Other non-Unix targets (WASM, bare-metal — not real deployments): keep the /// conservative default of always requiring elevation. #[cfg(not(any(unix, windows)))] diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 43ead6fa4..b128b5ef5 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -119,8 +119,8 @@ fn reload_daemon_for_coverage( quiet, notes, format!( - " could not kill the daemon: {err}. Continuing the deep sweep with whatever \ - is loaded." + " could not kill the daemon: {err}\n\ + Continuing the deep sweep with whatever is loaded." ), ); return; @@ -132,8 +132,8 @@ fn reload_daemon_for_coverage( quiet, notes, format!( - " could not start the daemon: {err}. Continuing the deep sweep with whatever \ - is loaded." + " could not start the daemon: {err}\n\ + Continuing the deep sweep with whatever is loaded." ), ); return; diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index 6f21905a4..d5dfb127d 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -61,7 +61,21 @@ impl SystemEffects { } impl Effects for SystemEffects { - fn stop_process(&mut self, _component: &str, pid: u32) -> Result<()> { + fn stop_process(&mut self, component: &str, pid: u32) -> Result<()> { + // The daemon's analyzed pid can go stale before execution (the deep + // sweep's coverage reload restarts it), so stop the CURRENT daemon via + // the same handler `uffs --daemon kill` uses (pid-file/socket + // discovery), falling back to the recorded pid; then wait for it to + // actually exit so its image is unlocked before the runtime binaries + // are deleted. + if component == "daemon" { + if crate::commands::daemon_mgmt::daemon_quiet(&crate::args::DaemonAction::Kill).is_err() + { + terminate_pid(pid)?; + } + wait_daemon_down(); + return Ok(()); + } terminate_pid(pid) } @@ -77,16 +91,38 @@ impl Effects for SystemEffects { } fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> { - for stem in stems { - let path = dir.join(exe_file_name(stem)); + // Best-effort across the whole set: one locked file must never trap + // the remaining deletions (the original failure mode: a lingering + // uffsd.exe aborted the loop and left 21 other binaries in place). + let failed: Vec = stems + .iter() + .map(|stem| dir.join(exe_file_name(stem))) // A running self-binary can't be deleted in place — defer it. - if self.is_self(&path) { - continue; + .filter(|path| !self.is_self(path)) + .filter(|path| remove_file_if_present(path).is_err()) + .collect(); + if failed.is_empty() { + return Ok(()); + } + // A just-stopped process can hold its image for a beat after the kill + // returns; give it one settle-and-retry pass before reporting. + std::thread::sleep(core::time::Duration::from_millis(750)); + let mut errors: Vec = Vec::new(); + for path in failed { + if let Err(err) = remove_file_if_present(&path) { + errors.push(format!("{}: {err}", path.display())); } - remove_file_if_present(&path) - .with_context(|| format!("removing {}", path.display()))?; } - Ok(()) + if errors.is_empty() { + Ok(()) + } else { + bail!( + "could not remove {} of {} file(s): {}", + errors.len(), + stems.len(), + errors.join("; ") + ) + } } fn delegate_winget(&mut self, package_id: &str, scope: Scope) -> Result<()> { @@ -157,6 +193,8 @@ fn remove_path_entry_impl(dir: &Path) -> Result<()> { /// directly, so just remove them. #[cfg(windows)] pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> { + use std::os::windows::process::CommandExt as _; + if paths.is_empty() { return Ok(()); } @@ -170,8 +208,13 @@ pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> { "ping 127.0.0.1 -n 3 >nul & {} & rem self-delete", deletes.join(" & ") ); + // `raw_arg`, NOT `args`: std's default Windows quoting wraps the script in + // quotes and backslash-escapes the inner `del "path"` quotes — an escaping + // scheme cmd.exe does not understand, so the deferred delete silently never + // deleted anything. The raw form hands cmd the `/c` payload verbatim. Command::new("cmd") - .args(["/c", &script]) + .raw_arg("/c") + .raw_arg(&script) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() @@ -242,6 +285,21 @@ fn run_quiet(command: &mut Command, what: &str) -> Result<()> { } } +/// Poll until the daemon is no longer reachable over IPC (up to 10s), then +/// give the OS a short beat to release the process image. Bounded — a wedged +/// teardown degrades to the delete-side retry, never a hang. +fn wait_daemon_down() { + let deadline = std::time::Instant::now() + core::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline { + if uffs_client::connect_sync::UffsClientSync::connect_raw().is_err() { + break; + } + std::thread::sleep(core::time::Duration::from_millis(250)); + } + // IPC down != image released; the loader lock lags the socket teardown. + std::thread::sleep(core::time::Duration::from_millis(500)); +} + /// Stop a process by pid (`taskkill` on Windows, `kill` on Unix). fn terminate_pid(pid: u32) -> Result<()> { let pid_str = pid.to_string(); diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 267bd963a..9f904ebc1 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -436,6 +436,7 @@ fn gather_strays(known: &[PathBuf], quiet: bool) -> GatherOutcome { let coverage_notes = coverage::ensure_drive_coverage(quiet); GATHER_PHASE.store(1, core::sync::atomic::Ordering::Relaxed); + sweep::dbg_gap(); let mut search = sweep::DaemonSearch; let find_started = std::time::Instant::now(); let candidates = sweep::find_strays(&mut search, known).unwrap_or_default(); diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index 6e2b90140..8f16b7256 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -226,68 +226,24 @@ pub(crate) fn build_plan( ) -> RemovalPlan { let mut groups: Vec = Vec::new(); - // 1. Services (the broker, elevated) — removed first conceptually. - if inventory.broker_service == BrokerServiceState::Installed { - let item = PlanItem { - target: PlanTarget::RemoveService { - service: uffs_broker_protocol::SERVICE_NAME.to_owned(), - }, - needs_elevation: true, - scope: ItemScope::Machine, - bytes: 0, - }; - push_group(&mut groups, "Services", vec![item], args.scope); - } - - // 2. Processes (stopped before their binaries are deleted). The broker is a - // LocalSystem **service** — `taskkill` can't stop it (returns exit 128, and - // the SCM would just restart it), so it is never a StopProcess item; the - // RemoveService item above stops + deletes it via `sc`. The daemon / MCP are - // ordinary user-owned processes, so a plain stop applies and needs no admin. - let processes: Vec = report - .running + // The working tools stay alive until the very end: tool binaries first, + // then PATH, then the shutdown of the running parts (daemon process + + // broker service), then the data dirs they had open, and finally the + // runtime binaries whose images were locked until that shutdown. The + // running uffs.exe / uffs-update.exe are deferred past process exit + // (self-delete) by the executor. + + // 1. Tool binaries — per root: unmanaged/dev delete, winget delegate. The + // runtime binaries (daemon, broker, MCP servers) are split into the final + // group below: their images are locked while those processes/services run. + let binaries: Vec = report + .roots .iter() - .filter(|process| !matches!(process.component, Component::Broker)) - .map(|process| PlanItem { - target: PlanTarget::StopProcess { - component: process.component.label().to_owned(), - pid: process.pid, - }, - needs_elevation: false, - scope: ItemScope::Any, - bytes: 0, - }) + .filter_map(|root| binary_item(root, StemSet::Tools)) .collect(); - push_group( - &mut groups, - "Processes (stopped first)", - processes, - args.scope, - ); - - // 3. Binaries — per root: unmanaged/dev delete, winget delegate. - let binaries: Vec = report.roots.iter().filter_map(binary_item).collect(); push_group(&mut groups, "Binaries", binaries, args.scope); - // 4. Data / cache / config dirs that exist (skip config under --keep-config). - let dirs: Vec = inventory - .dirs - .iter() - .filter(|dir| dir.exists) - .filter(|dir| !(args.keep_config && dir.kind == ArtifactKind::Config)) - .map(|dir| PlanItem { - target: PlanTarget::DeleteDir { - path: dir.path.clone(), - label: dir.kind.label(), - }, - needs_elevation: false, - scope: ItemScope::User, - bytes: dir.size_bytes, - }) - .collect(); - push_group(&mut groups, "Data / cache / config", dirs, args.scope); - - // 5. PATH entries that point at a removed unmanaged/dev root that is + // 2. PATH entries that point at a removed unmanaged/dev root that is // *dedicated* to UFFS (only uffs* files) — provably ours, so safe to drop. A // shared bin dir (~/bin, ~/.local/bin) is filtered out upstream and never // appears here. WinGet roots are managed by winget. Skipped under --no-path. @@ -320,6 +276,74 @@ pub(crate) fn build_plan( push_group(&mut groups, "PATH", path_items, args.scope); } + // 3. Shutdown of the running parts — LAST among the live pieces, so the + // tooling stays usable during the run. The broker is a LocalSystem + // **service** — `taskkill` can't stop it (returns exit 128, and the SCM + // would just restart it), so it is never a StopProcess item; the + // RemoveService item stops + deletes it via `sc`. The daemon / MCP are + // ordinary user-owned processes, so a plain stop applies and needs no + // admin. (At execution the daemon is re-discovered by its pid file — the + // analyzed pid can go stale when the deep sweep reloads it.) + let mut shutdown: Vec = report + .running + .iter() + .filter(|process| !matches!(process.component, Component::Broker)) + .map(|process| PlanItem { + target: PlanTarget::StopProcess { + component: process.component.label().to_owned(), + pid: process.pid, + }, + needs_elevation: false, + scope: ItemScope::Any, + bytes: 0, + }) + .collect(); + if inventory.broker_service == BrokerServiceState::Installed { + shutdown.push(PlanItem { + target: PlanTarget::RemoveService { + service: uffs_broker_protocol::SERVICE_NAME.to_owned(), + }, + needs_elevation: true, + scope: ItemScope::Machine, + bytes: 0, + }); + } + push_group(&mut groups, "Shutdown (stopped last)", shutdown, args.scope); + + // 4. Data / cache / config dirs that exist (skip config under + // --keep-config). After the daemon shutdown: a running daemon holds open + // handles (pid file, socket, mmap'd caches) inside these dirs. + let dirs: Vec = inventory + .dirs + .iter() + .filter(|dir| dir.exists) + .filter(|dir| !(args.keep_config && dir.kind == ArtifactKind::Config)) + .map(|dir| PlanItem { + target: PlanTarget::DeleteDir { + path: dir.path.clone(), + label: dir.kind.label(), + }, + needs_elevation: false, + scope: ItemScope::User, + bytes: dir.size_bytes, + }) + .collect(); + push_group(&mut groups, "Data / cache / config", dirs, args.scope); + + // 5. Runtime binaries — deletable only now that their processes/services + // are stopped (Windows locks a running image). + let runtime: Vec = report + .roots + .iter() + .filter_map(|root| binary_item(root, StemSet::Runtime)) + .collect(); + push_group( + &mut groups, + "Runtime binaries (after shutdown)", + runtime, + args.scope, + ); + RemovalPlan { groups } } @@ -361,8 +385,32 @@ fn paths_equal_ignore_case(left: &Path, right: &Path) -> bool { .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 { +/// Binary stems whose images are locked while the resident parts run (the +/// daemon, the broker service, the MCP servers). Deleted in the final plan +/// group, after the shutdown items; every other stem is a plain tool binary. +const RUNTIME_STEMS: &[&str] = &["uffsd", "uffs-broker", "uffsmcp", "uffs-mcp-http"]; + +/// Which slice of a root's binaries a [`binary_item`] call covers. +#[derive(Clone, Copy, PartialEq, Eq)] +enum StemSet { + /// Plain tool binaries — deletable any time (group 1). + Tools, + /// [`RUNTIME_STEMS`] — deletable only after the shutdown group. + Runtime, +} + +/// Whether `stem` names a runtime binary (see [`RUNTIME_STEMS`]). +fn is_runtime_stem(stem: &str) -> bool { + RUNTIME_STEMS + .iter() + .any(|runtime| runtime.eq_ignore_ascii_case(stem)) +} + +/// Build the per-root binary plan item for the requested stem set, or `None` +/// when the root has no matching binaries. A `WinGet` root delegates whole to +/// `winget uninstall` in the Tools pass (winget owns the stop/delete order for +/// its own package), so its Runtime pass is empty. +fn binary_item(root: &InstallRoot, set: StemSet) -> Option { if root.binaries.is_empty() { return None; } @@ -373,15 +421,31 @@ fn binary_item(root: &InstallRoot) -> Option { ItemScope::User }; let target = match root.channel { - Channel::WinGet => PlanTarget::DelegateWinget { - package_id: WINGET_PACKAGE_ID.to_owned(), - scope: root.scope, - dir: root.dir.clone(), - }, - Channel::Unmanaged | Channel::DevBuild | Channel::Unknown => PlanTarget::DeleteBinaries { - dir: root.dir.clone(), - stems: root.binaries.iter().map(|bin| bin.name.clone()).collect(), - }, + Channel::WinGet => { + if set == StemSet::Runtime { + return None; + } + PlanTarget::DelegateWinget { + package_id: WINGET_PACKAGE_ID.to_owned(), + scope: root.scope, + dir: root.dir.clone(), + } + } + Channel::Unmanaged | Channel::DevBuild | Channel::Unknown => { + let stems: Vec = root + .binaries + .iter() + .filter(|bin| (set == StemSet::Runtime) == is_runtime_stem(&bin.name)) + .map(|bin| bin.name.clone()) + .collect(); + if stems.is_empty() { + return None; + } + PlanTarget::DeleteBinaries { + dir: root.dir.clone(), + stems, + } + } }; Some(PlanItem { target, @@ -446,336 +510,4 @@ const fn scope_admits(requested: UninstallScope, item: ItemScope) -> bool { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - - #[cfg(windows)] - use super::build_stray_plan; - use super::{PlanTarget, RemovalPlan, build_plan}; - use crate::commands::uninstall::args::{UninstallArgs, UninstallScope}; - use crate::commands::uninstall::inventory::{ - ArtifactDir, ArtifactKind, BrokerServiceState, Inventory, - }; - use crate::commands::update::model::{ - BinaryInfo, Channel, Component, DetectionReport, InstallRoot, RunningProcess, Scope, - }; - - fn root(channel: Channel, scope: Scope, dir: &str) -> InstallRoot { - InstallRoot { - dir: PathBuf::from(dir), - channel, - scope, - anchored_by: Vec::new(), - binaries: vec![BinaryInfo { - name: "uffs".to_owned(), - version: Some("0.6.16".to_owned()), - }], - } - } - - fn inventory(broker: BrokerServiceState, config_size: u64) -> Inventory { - Inventory { - dirs: vec![ - ArtifactDir { - kind: ArtifactKind::Cache, - path: PathBuf::from("/x/cache"), - exists: true, - size_bytes: 2048, - }, - ArtifactDir { - kind: ArtifactKind::Config, - path: PathBuf::from("/x/config"), - exists: true, - size_bytes: config_size, - }, - ], - broker_service: broker, - } - } - - fn has_target(plan: &RemovalPlan, predicate: impl Fn(&PlanTarget) -> bool) -> bool { - plan.items().any(|item| predicate(&item.target)) - } - - /// Build a plan with no PATH entries (PATH has its own dedicated test). - fn built(report: &DetectionReport, inventory: &Inventory, args: &UninstallArgs) -> RemovalPlan { - build_plan(report, inventory, args, &[]) - } - - #[test] - fn winget_root_is_delegated_not_deleted() { - let report = DetectionReport { - roots: vec![root(Channel::WinGet, Scope::User, r"C:\winget\uffs")], - running: Vec::new(), - }; - let plan = built( - &report, - &inventory(BrokerServiceState::Absent, 1024), - &UninstallArgs::default(), - ); - assert!(has_target(&plan, |target| matches!( - target, - PlanTarget::DelegateWinget { .. } - ))); - assert!(!has_target(&plan, |target| matches!( - target, - PlanTarget::DeleteBinaries { .. } - ))); - } - - #[test] - fn machine_root_needs_elevation() { - let report = DetectionReport { - roots: vec![root( - Channel::Unmanaged, - Scope::Machine, - r"C:\Program Files\uffs", - )], - running: Vec::new(), - }; - let plan = built( - &report, - &inventory(BrokerServiceState::Absent, 1024), - &UninstallArgs::default(), - ); - assert!(plan.requires_elevation()); - } - - #[test] - fn service_present_requires_elevation_and_is_first() { - let report = DetectionReport { - roots: Vec::new(), - running: Vec::new(), - }; - let plan = built( - &report, - &inventory(BrokerServiceState::Installed, 1024), - &UninstallArgs::default(), - ); - assert!(plan.requires_elevation()); - assert!(has_target(&plan, |target| matches!( - target, - PlanTarget::RemoveService { .. } - ))); - assert_eq!(plan.groups.first().expect("a group").title, "Services"); - } - - #[test] - fn keep_config_drops_the_config_dir() { - let report = DetectionReport { - roots: Vec::new(), - running: Vec::new(), - }; - let inv = inventory(BrokerServiceState::Absent, 4096); - let with_config = built(&report, &inv, &UninstallArgs::default()); - let keep = UninstallArgs { - keep_config: true, - ..UninstallArgs::default() - }; - let without_config = built(&report, &inv, &keep); - assert!(with_config.total_bytes() > without_config.total_bytes()); - } - - #[test] - fn scope_user_excludes_the_machine_service() { - let report = DetectionReport { - roots: Vec::new(), - running: Vec::new(), - }; - let user_only = UninstallArgs { - scope: UninstallScope::User, - ..UninstallArgs::default() - }; - let plan = built( - &report, - &inventory(BrokerServiceState::Installed, 1024), - &user_only, - ); - assert!(!has_target(&plan, |target| matches!( - target, - PlanTarget::RemoveService { .. } - ))); - assert!(!plan.requires_elevation()); - } - - #[test] - fn running_process_becomes_a_stop_item() { - let report = DetectionReport { - roots: Vec::new(), - running: vec![RunningProcess { - component: Component::Daemon, - pid: 4242, - image_path: None, - command_line: None, - version: None, - }], - }; - let plan = built( - &report, - &inventory(BrokerServiceState::Absent, 1024), - &UninstallArgs::default(), - ); - assert!(has_target(&plan, |target| matches!( - target, - PlanTarget::StopProcess { .. } - ))); - } - - #[test] - fn drop_elevation_required_removes_broker_keeps_the_rest() { - let report = DetectionReport { - roots: Vec::new(), - running: vec![ - RunningProcess { - component: Component::Broker, - pid: 11, - image_path: None, - command_line: None, - version: None, - }, - RunningProcess { - component: Component::Daemon, - pid: 22, - image_path: None, - command_line: None, - version: None, - }, - ], - }; - // Broker service installed -> an admin-only RemoveService item. The - // broker *process* is filtered out (it's a service, stopped via sc, not - // taskkill); only the user-owned daemon stop remains, needing no admin. - let mut plan = built( - &report, - &inventory(BrokerServiceState::Installed, 1024), - &UninstallArgs::default(), - ); - assert!( - plan.requires_elevation(), - "broker service + process need admin" - ); - - let dropped = plan.drop_elevation_required(); - assert!(!plan.requires_elevation(), "admin-only items were dropped"); - assert!( - !dropped.is_empty() && dropped.iter().all(|desc| !desc.is_empty()), - "the dropped items are returned as human descriptions for the summary" - ); - assert!( - !has_target(&plan, |target| matches!( - target, - PlanTarget::RemoveService { .. } - )), - "the broker service item is gone" - ); - let stop_pids: Vec = plan - .items() - .filter_map(|item| { - if let PlanTarget::StopProcess { pid, .. } = &item.target { - Some(*pid) - } else { - None - } - }) - .collect(); - assert_eq!(stop_pids, vec![22], "only the daemon stop survives"); - } - - #[test] - #[cfg(windows)] - fn stray_plan_is_one_group_of_unprivileged_delete_file_items() { - use crate::commands::uninstall::sweep::StrayHit; - - assert!(build_stray_plan(&[]).is_empty(), "no strays -> empty plan"); - let strays = vec![ - StrayHit { - path: PathBuf::from("/home/me/Downloads/uffs"), - version: Some("0.5.0".to_owned()), - }, - StrayHit { - path: PathBuf::from("/tmp/x_compact.uffs"), - version: None, - }, - ]; - let plan = build_stray_plan(&strays); - assert_eq!(plan.item_count(), 2); - assert!( - plan.items() - .all(|item| matches!(item.target, PlanTarget::DeleteFile { .. })), - "every stray item is a DeleteFile" - ); - assert!( - !plan.requires_elevation(), - "strays never require up-front elevation (best-effort on failure)" - ); - } - - #[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); - // The 4th arg is the already-vetted removable-dir set; a case-insensitive - // match to the removed root → offered. (Exclusivity vetting is tested in - // analyze::removable_path_dirs; here we exercise build_plan's emission.) - let on_path = [PathBuf::from(r"c:\users\me\bin")]; - let offered = build_plan(&report, &inv, &UninstallArgs::default(), &on_path); - assert!(has_target(&offered, |target| matches!( - target, - PlanTarget::RemovePathEntry { .. } - ))); - // --no-path suppresses the PATH group entirely. - let no_path = UninstallArgs { - no_path: true, - ..UninstallArgs::default() - }; - let suppressed = build_plan(&report, &inv, &no_path, &on_path); - assert!(!has_target(&suppressed, |target| matches!( - target, - PlanTarget::RemovePathEntry { .. } - ))); - // A PATH entry that does not match any root is never touched. - let unrelated = [PathBuf::from(r"C:\unrelated")]; - let untouched = build_plan(&report, &inv, &UninstallArgs::default(), &unrelated); - assert!(!has_target(&untouched, |target| matches!( - target, - PlanTarget::RemovePathEntry { .. } - ))); - } - - #[cfg(unix)] - #[test] - fn unix_user_writable_root_skips_escalation_root_owned_flags_it() { - use std::path::Path; - - use super::binaries_need_escalation; - // The temp dir is user-writable → removable without sudo. - assert!(!binaries_need_escalation( - Scope::Unknown, - &std::env::temp_dir() - )); - // A non-existent / unwritable path → flagged for escalation. - assert!(binaries_need_escalation( - Scope::Unknown, - Path::new("/nonexistent/uffs-escalation-probe") - )); - } - - #[cfg(windows)] - #[test] - fn windows_escalation_follows_machine_scope() { - use std::path::Path; - - use super::binaries_need_escalation; - assert!(binaries_need_escalation( - Scope::Machine, - Path::new(r"C:\Program Files\uffs") - )); - assert!(!binaries_need_escalation( - Scope::User, - Path::new(r"C:\Users\me\bin") - )); - } -} +mod tests; diff --git a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs new file mode 100644 index 000000000..07d617b6d --- /dev/null +++ b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Unit tests for the removal-plan construction ([`super`]) — extracted into a +//! sibling module (the `compact_cache/tests.rs` / `backend_tests.rs` pattern) +//! so `plan.rs` stays within the file-size policy. + +use std::path::PathBuf; + +#[cfg(windows)] +use super::build_stray_plan; +use super::{PlanTarget, RemovalPlan, build_plan}; +use crate::commands::uninstall::args::{UninstallArgs, UninstallScope}; +use crate::commands::uninstall::inventory::{ + ArtifactDir, ArtifactKind, BrokerServiceState, Inventory, +}; +use crate::commands::update::model::{ + BinaryInfo, Channel, Component, DetectionReport, InstallRoot, RunningProcess, Scope, +}; + +fn root(channel: Channel, scope: Scope, dir: &str) -> InstallRoot { + InstallRoot { + dir: PathBuf::from(dir), + channel, + scope, + anchored_by: Vec::new(), + binaries: vec![BinaryInfo { + name: "uffs".to_owned(), + version: Some("0.6.16".to_owned()), + }], + } +} + +fn inventory(broker: BrokerServiceState, config_size: u64) -> Inventory { + Inventory { + dirs: vec![ + ArtifactDir { + kind: ArtifactKind::Cache, + path: PathBuf::from("/x/cache"), + exists: true, + size_bytes: 2048, + }, + ArtifactDir { + kind: ArtifactKind::Config, + path: PathBuf::from("/x/config"), + exists: true, + size_bytes: config_size, + }, + ], + broker_service: broker, + } +} + +fn has_target(plan: &RemovalPlan, predicate: impl Fn(&PlanTarget) -> bool) -> bool { + plan.items().any(|item| predicate(&item.target)) +} + +/// Build a plan with no PATH entries (PATH has its own dedicated test). +fn built(report: &DetectionReport, inventory: &Inventory, args: &UninstallArgs) -> RemovalPlan { + build_plan(report, inventory, args, &[]) +} + +#[test] +fn winget_root_is_delegated_not_deleted() { + let report = DetectionReport { + roots: vec![root(Channel::WinGet, Scope::User, r"C:\winget\uffs")], + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::DelegateWinget { .. } + ))); + assert!(!has_target(&plan, |target| matches!( + target, + PlanTarget::DeleteBinaries { .. } + ))); +} + +#[test] +fn machine_root_needs_elevation() { + let report = DetectionReport { + roots: vec![root( + Channel::Unmanaged, + Scope::Machine, + r"C:\Program Files\uffs", + )], + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(plan.requires_elevation()); +} + +#[test] +fn service_present_requires_elevation_and_shuts_down_before_data() { + let report = DetectionReport { + roots: Vec::new(), + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &UninstallArgs::default(), + ); + assert!(plan.requires_elevation()); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + ))); + // Teardown-last ordering: the tools stay usable during the run, so the + // shutdown group comes late — but still BEFORE the data dirs (a + // running daemon holds open handles inside them). + let titles: Vec<&str> = plan.groups.iter().map(|group| group.title).collect(); + let shutdown = titles + .iter() + .position(|title| *title == "Shutdown (stopped last)") + .expect("a shutdown group"); + let data = titles + .iter() + .position(|title| *title == "Data / cache / config") + .expect("a data group"); + assert!(shutdown < data, "shutdown must precede data: {titles:?}"); +} + +#[test] +fn runtime_binaries_split_into_the_post_shutdown_group() { + // A root holding both tool and runtime binaries: the tools delete in + // the first group; uffsd/uffs-broker (image locked while running) land + // in "Runtime binaries (after shutdown)", after the shutdown group. + let mut mixed = root(Channel::Unmanaged, Scope::User, "/opt/uffs"); + mixed.binaries = ["uffs", "analyze-diff", "uffsd", "uffs-broker"] + .into_iter() + .map(|name| BinaryInfo { + name: name.to_owned(), + version: None, + }) + .collect(); + let report = DetectionReport { + roots: vec![mixed], + running: Vec::new(), + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + + let stems_of = |title: &str| -> Vec { + plan.groups + .iter() + .find(|group| group.title == title) + .into_iter() + .flat_map(|group| &group.items) + .filter_map(|item| { + if let PlanTarget::DeleteBinaries { stems, .. } = &item.target { + Some(stems.clone()) + } else { + None + } + }) + .flatten() + .collect() + }; + assert_eq!(stems_of("Binaries"), vec!["uffs", "analyze-diff"]); + assert_eq!(stems_of("Runtime binaries (after shutdown)"), vec![ + "uffsd", + "uffs-broker" + ]); + let titles: Vec<&str> = plan.groups.iter().map(|group| group.title).collect(); + let tools = titles + .iter() + .position(|title| *title == "Binaries") + .expect("tools"); + let runtime = titles + .iter() + .position(|title| *title == "Runtime binaries (after shutdown)") + .expect("runtime"); + assert!(tools < runtime, "runtime group must be last: {titles:?}"); +} + +#[test] +fn keep_config_drops_the_config_dir() { + let report = DetectionReport { + roots: Vec::new(), + running: Vec::new(), + }; + let inv = inventory(BrokerServiceState::Absent, 4096); + let with_config = built(&report, &inv, &UninstallArgs::default()); + let keep = UninstallArgs { + keep_config: true, + ..UninstallArgs::default() + }; + let without_config = built(&report, &inv, &keep); + assert!(with_config.total_bytes() > without_config.total_bytes()); +} + +#[test] +fn scope_user_excludes_the_machine_service() { + let report = DetectionReport { + roots: Vec::new(), + running: Vec::new(), + }; + let user_only = UninstallArgs { + scope: UninstallScope::User, + ..UninstallArgs::default() + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &user_only, + ); + assert!(!has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + ))); + assert!(!plan.requires_elevation()); +} + +#[test] +fn running_process_becomes_a_stop_item() { + let report = DetectionReport { + roots: Vec::new(), + running: vec![RunningProcess { + component: Component::Daemon, + pid: 4242, + image_path: None, + command_line: None, + version: None, + }], + }; + let plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!(has_target(&plan, |target| matches!( + target, + PlanTarget::StopProcess { .. } + ))); +} + +#[test] +fn drop_elevation_required_removes_broker_keeps_the_rest() { + let report = DetectionReport { + roots: Vec::new(), + running: vec![ + RunningProcess { + component: Component::Broker, + pid: 11, + image_path: None, + command_line: None, + version: None, + }, + RunningProcess { + component: Component::Daemon, + pid: 22, + image_path: None, + command_line: None, + version: None, + }, + ], + }; + // Broker service installed -> an admin-only RemoveService item. The + // broker *process* is filtered out (it's a service, stopped via sc, not + // taskkill); only the user-owned daemon stop remains, needing no admin. + let mut plan = built( + &report, + &inventory(BrokerServiceState::Installed, 1024), + &UninstallArgs::default(), + ); + assert!( + plan.requires_elevation(), + "broker service + process need admin" + ); + + let dropped = plan.drop_elevation_required(); + assert!(!plan.requires_elevation(), "admin-only items were dropped"); + assert!( + !dropped.is_empty() && dropped.iter().all(|desc| !desc.is_empty()), + "the dropped items are returned as human descriptions for the summary" + ); + assert!( + !has_target(&plan, |target| matches!( + target, + PlanTarget::RemoveService { .. } + )), + "the broker service item is gone" + ); + let stop_pids: Vec = plan + .items() + .filter_map(|item| { + if let PlanTarget::StopProcess { pid, .. } = &item.target { + Some(*pid) + } else { + None + } + }) + .collect(); + assert_eq!(stop_pids, vec![22], "only the daemon stop survives"); +} + +#[test] +#[cfg(windows)] +fn stray_plan_is_one_group_of_unprivileged_delete_file_items() { + use crate::commands::uninstall::sweep::StrayHit; + + assert!(build_stray_plan(&[]).is_empty(), "no strays -> empty plan"); + let strays = vec![ + StrayHit { + path: PathBuf::from("/home/me/Downloads/uffs"), + version: Some("0.5.0".to_owned()), + }, + StrayHit { + path: PathBuf::from("/tmp/x_compact.uffs"), + version: None, + }, + ]; + let plan = build_stray_plan(&strays); + assert_eq!(plan.item_count(), 2); + assert!( + plan.items() + .all(|item| matches!(item.target, PlanTarget::DeleteFile { .. })), + "every stray item is a DeleteFile" + ); + assert!( + !plan.requires_elevation(), + "strays never require up-front elevation (best-effort on failure)" + ); +} + +#[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); + // The 4th arg is the already-vetted removable-dir set; a case-insensitive + // match to the removed root → offered. (Exclusivity vetting is tested in + // analyze::removable_path_dirs; here we exercise build_plan's emission.) + let on_path = [PathBuf::from(r"c:\users\me\bin")]; + let offered = build_plan(&report, &inv, &UninstallArgs::default(), &on_path); + assert!(has_target(&offered, |target| matches!( + target, + PlanTarget::RemovePathEntry { .. } + ))); + // --no-path suppresses the PATH group entirely. + let no_path = UninstallArgs { + no_path: true, + ..UninstallArgs::default() + }; + let suppressed = build_plan(&report, &inv, &no_path, &on_path); + assert!(!has_target(&suppressed, |target| matches!( + target, + PlanTarget::RemovePathEntry { .. } + ))); + // A PATH entry that does not match any root is never touched. + let unrelated = [PathBuf::from(r"C:\unrelated")]; + let untouched = build_plan(&report, &inv, &UninstallArgs::default(), &unrelated); + assert!(!has_target(&untouched, |target| matches!( + target, + PlanTarget::RemovePathEntry { .. } + ))); +} + +#[cfg(unix)] +#[test] +fn unix_user_writable_root_skips_escalation_root_owned_flags_it() { + use std::path::Path; + + use super::binaries_need_escalation; + // The temp dir is user-writable → removable without sudo. + assert!(!binaries_need_escalation( + Scope::Unknown, + &std::env::temp_dir() + )); + // A non-existent / unwritable path → flagged for escalation. + assert!(binaries_need_escalation( + Scope::Unknown, + Path::new("/nonexistent/uffs-escalation-probe") + )); +} + +#[cfg(windows)] +#[test] +fn windows_escalation_follows_machine_scope() { + use std::path::Path; + + use super::binaries_need_escalation; + assert!(binaries_need_escalation( + Scope::Machine, + Path::new(r"C:\Program Files\uffs") + )); + assert!(!binaries_need_escalation( + Scope::User, + Path::new(r"C:\Users\me\bin") + )); +} diff --git a/crates/uffs-cli/src/commands/uninstall/remove.rs b/crates/uffs-cli/src/commands/uninstall/remove.rs index 3cbfa6dc1..60337b152 100644 --- a/crates/uffs-cli/src/commands/uninstall/remove.rs +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -227,10 +227,12 @@ mod tests { let plan = full_plan(); let mut effects = RecordingEffects::default(); let outcome = execute(&plan, &mut effects); - // Processes (stop) precede Binaries (delete), which precede Data dirs. + // Teardown-last ordering: tool binaries first (the tooling stays + // usable during the run), then the daemon shutdown, then the data + // dirs it had open handles in. assert_eq!(effects.calls, vec![ - "stop_process:daemon:7".to_owned(), "delete_binaries:/opt/uffs:1".to_owned(), + "stop_process:daemon:7".to_owned(), "remove_dir:/x/cache".to_owned(), ]); assert!(outcome.all_done()); diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index 2a3f1126b..120145866 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -37,6 +37,15 @@ pub(crate) fn dbg_line(msg: &str) { } } +/// Blank separator printed before the first `[sweep]` diagnostic block, so the +/// diagnostics never run back-to-back into preceding output. `-v` only. +#[expect(clippy::print_stdout, reason = "verbose-gated deep-sweep diagnostics")] +pub(crate) fn dbg_gap() { + if SWEEP_VERBOSE.load(core::sync::atomic::Ordering::Relaxed) { + println!(); + } +} + /// UFFS cache/cursor data-file patterns the sweep searches for. The executable /// patterns are derived from the shared family set (see [`family_stems`]). const CACHE_PATTERNS: &[&str] = &["*_compact.uffs", "*_usn.cursor"]; diff --git a/crates/uffs-daemon/src/lifecycle.rs b/crates/uffs-daemon/src/lifecycle.rs index 21d531939..189a3a76f 100644 --- a/crates/uffs-daemon/src/lifecycle.rs +++ b/crates/uffs-daemon/src/lifecycle.rs @@ -298,6 +298,10 @@ impl LifecycleManager { "command_line": command_line, "version": env!("CARGO_PKG_VERSION"), "started_unix": started_unix, + // Whether this daemon runs elevated. The CLI's daemon-management + // elevation gate reads it: a non-elevated daemon in the caller's + // own %LOCALAPPDATA% is theirs to stop/restart without admin. + "elevated": uffs_mft::is_elevated(), }); let Ok(content) = serde_json::to_string_pretty(&state) else { return; From 45532a60e95e73196879ecab9c63970750cb9d0c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:44:42 -0700 Subject: [PATCH 32/40] feat(uninstall): sweep-elevation gate for the no-broker path + graceful daemon stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without the Access Broker a non-elevated daemon cannot read the MFT, so the deep sweep silently came up empty ("could not start the daemon: Failed to start daemon" -> 0 candidates). Now the sweep is an explicit up-front decision: - New sweep gate (asked FIRST, before the gather starts) — only when it matters: coverage incomplete AND not elevated AND no broker pipe serving. d = deep sweep — start the daemon now (one UAC prompt) s = skip the deep sweep (standard locations only) Choosing d routes the coverage reload through the existing `--daemon start --elevate` machinery (connect_with_elevation), so the UAC prompt happens right then and there. `--yes` / `--dry-run` never pop a surprise UAC: they skip with an explanatory note. Broker-serving, elevated, or already-covered runs proceed silently as before. - The daemon stop now tries the graceful shutdown RPC first: it needs no OS privileges, so it also stops the ELEVATED daemon the sweep may have started (which taskkill cannot touch), before falling back to the kill handler and the recorded pid. SweepDecision is threaded through start/finish_stray_gather and ensure_drive_coverage(quiet, elevate_daemon); --no-deep-sweep folds into the same decision. Validated with cargo clippy (host) + cargo xwin clippy (Windows, prod + tests); 44 tests pass. Co-Authored-By: Claude Fable 5 --- .../src/commands/uninstall/coverage.rs | 35 ++++-- .../src/commands/uninstall/effects.rs | 18 ++- crates/uffs-cli/src/commands/uninstall/mod.rs | 116 +++++++++++++++--- 3 files changed, 135 insertions(+), 34 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index b128b5ef5..39c536cf4 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -41,12 +41,26 @@ const SHUTDOWN_WAIT: Duration = Duration::from_secs(15); /// Poll interval while waiting for shutdown. const POLL_INTERVAL: Duration = Duration::from_millis(500); +/// Whether the daemon already covers every NTFS drive — a cheap RPC check used +/// by the sweep-elevation decision *before* the gather starts (a daemon with +/// full coverage needs no reload, elevated or not). +pub(crate) fn coverage_complete() -> bool { + let all = detect_ntfs_drives(); + if all.is_empty() { + return true; + } + let managed = current_managed_drives(); + all.iter().all(|drive| managed.contains(drive)) +} + /// Ensure the daemon covers every NTFS drive before the deep sweep. No-op when /// coverage is already complete; otherwise reload the daemon (kill + start) -/// via the real CLI handlers. Returns the deferred narration notes (always -/// empty in loud mode, where everything printed live). Best-effort: any -/// failure just means the sweep covers whatever is currently loaded. -pub(crate) fn ensure_drive_coverage(quiet: bool) -> Vec { +/// via the real CLI handlers — with `elevate_daemon` the start requests a UAC +/// prompt (the user opted in at the sweep gate: without the Access Broker a +/// daemon can only read the MFT elevated). Returns the deferred narration +/// notes (always empty in loud mode, where everything printed live). +/// Best-effort: any failure just means the sweep covers whatever is loaded. +pub(crate) fn ensure_drive_coverage(quiet: bool, elevate_daemon: bool) -> Vec { let mut notes: Vec = Vec::new(); let all = detect_ntfs_drives(); if all.is_empty() { @@ -62,7 +76,7 @@ pub(crate) fn ensure_drive_coverage(quiet: bool) -> Vec { // The daemon already covers every system drive — nothing to do. return notes; } - reload_daemon_for_coverage(&all, &missing, quiet, &mut notes); + reload_daemon_for_coverage(&all, &missing, quiet, elevate_daemon, &mut notes); notes } @@ -89,6 +103,7 @@ fn reload_daemon_for_coverage( all: &[DriveLetter], missing: &[DriveLetter], quiet: bool, + elevate_daemon: bool, notes: &mut Vec, ) { let list = missing @@ -127,7 +142,7 @@ fn reload_daemon_for_coverage( } wait_until_daemon_down(); - if let Err(err) = run_handler(quiet, &start_action()) { + if let Err(err) = run_handler(quiet, &start_action(elevate_daemon)) { emit( quiet, notes, @@ -175,8 +190,10 @@ fn emit(quiet: bool, notes: &mut Vec, line: String) { } /// The [`DaemonAction::Start`] a bare `uffs --daemon start` produces: auto- -/// discover every NTFS drive, use the cache, default logging, no UAC prompt. -fn start_action() -> DaemonAction { +/// discover every NTFS drive, use the cache, default logging. `elevate` +/// requests the UAC prompt (`--daemon start --elevate`) for the no-broker +/// sweep path the user opted into. +fn start_action(elevate: bool) -> DaemonAction { DaemonAction::Start { mft_file: Vec::new(), data_dir: None, @@ -184,7 +201,7 @@ fn start_action() -> DaemonAction { no_cache: false, log_level: "info".to_owned(), log_file: None, - elevate: false, + elevate, } } diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index d5dfb127d..473eee843 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -63,13 +63,19 @@ impl SystemEffects { impl Effects for SystemEffects { fn stop_process(&mut self, component: &str, pid: u32) -> Result<()> { // The daemon's analyzed pid can go stale before execution (the deep - // sweep's coverage reload restarts it), so stop the CURRENT daemon via - // the same handler `uffs --daemon kill` uses (pid-file/socket - // discovery), falling back to the recorded pid; then wait for it to - // actually exit so its image is unlocked before the runtime binaries - // are deleted. + // sweep's coverage reload restarts it), so stop the CURRENT daemon: + // graceful shutdown RPC first — it needs no OS privileges, so it also + // stops an ELEVATED daemon (the no-broker sweep's UAC start) that + // taskkill could not touch — then the `uffs --daemon kill` handler + // (pid-file/socket discovery), then the recorded pid as a last resort. + // Finally wait for the process to actually exit so its image is + // unlocked before the runtime binaries are deleted. if component == "daemon" { - if crate::commands::daemon_mgmt::daemon_quiet(&crate::args::DaemonAction::Kill).is_err() + let stopped = uffs_client::connect_sync::UffsClientSync::connect_raw() + .is_ok_and(|mut client| client.shutdown().is_ok()); + if !stopped + && crate::commands::daemon_mgmt::daemon_quiet(&crate::args::DaemonAction::Kill) + .is_err() { terminate_pid(pid)?; } diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 9f904ebc1..3d00f99f5 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -75,12 +75,17 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { #[cfg(windows)] sweep::set_verbose(parsed.verbose); - // Overlap the slow work with the user's first decision: the drive-coverage - // reload + deep sweep start (quietly) in the background the moment the - // command fires, while the elevation question is on screen. `-v` runs - // sequentially instead so its live diagnostics stay readable. + // The deep-sweep decision comes first: a broker-less, non-elevated sweep + // needs the user to opt into a UAC daemon start (or skip the sweep). #[cfg(windows)] - let gather = start_stray_gather(&parsed, &removal_plan); + let sweep = sweep_decision(&parsed)?; + + // Overlap the slow work with the user's next decision: the drive-coverage + // reload + deep sweep start (quietly) in the background right away, while + // the elevation question is on screen. `-v` runs sequentially instead so + // its live diagnostics stay readable. + #[cfg(windows)] + let gather = start_stray_gather(&parsed, &removal_plan, sweep); let gate = elevation_gate(&parsed, &mut removal_plan)?; let skipped_elevation: Vec = match &gate { @@ -92,7 +97,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // COMPLETE picture at once: CORE table + inventory, EXTRA table, the action // plan, and the gate notes. Nothing was shown while data was in flight. #[cfg(windows)] - let gathered = finish_stray_gather(&parsed, &removal_plan, gather); + let gathered = finish_stray_gather(&removal_plan, gather, sweep); #[cfg(windows)] let stray_plan = &gathered.stray_plan; #[cfg(not(windows))] @@ -386,6 +391,72 @@ fn plan_dirs(plan: &RemovalPlan) -> Vec { .collect() } +/// The up-front deep-sweep decision. Windows-only. +#[cfg(windows)] +#[derive(Clone, Copy)] +enum SweepDecision { + /// Run the sweep; `elevate_daemon` = start the index daemon with a UAC + /// prompt (the no-broker path the user opted into at the sweep gate). + Proceed { + /// Whether the daemon start requests elevation (`--elevate`). + elevate_daemon: bool, + }, + /// Skip the sweep entirely (`--no-deep-sweep`, or the user/mode declined + /// the elevation a broker-less sweep would need). + Skip, +} + +/// Decide up front whether (and how) the deep sweep runs. A complete sweep +/// needs the index daemon covering every drive; without the Access Broker a +/// daemon can only read the MFT **elevated**, so when coverage is incomplete, +/// this run is not elevated, and no broker pipe is serving, the user chooses: +/// start the daemon with a UAC prompt now, or skip the sweep. `--yes` and +/// `--dry-run` never pop a surprise UAC — they skip with a note instead. +#[cfg(windows)] +#[expect(clippy::print_stdout, reason = "CLI user-facing output")] +fn sweep_decision(parsed: &UninstallArgs) -> Result { + /// Short broker-pipe probe (same budget as the daemon-management gate). + const BROKER_PROBE_MS: u32 = 600; + + if parsed.no_deep_sweep { + return Ok(SweepDecision::Skip); + } + if coverage::coverage_complete() + || uffs_mft::platform::is_elevated() + || uffs_winsvc::pipe_serving(uffs_broker_protocol::PIPE_NAME, BROKER_PROBE_MS) + { + return Ok(SweepDecision::Proceed { + elevate_daemon: false, + }); + } + // A complete sweep would need an elevated daemon start. + if parsed.dry_run || parsed.assume_yes { + println!( + "\nDeep sweep skipped: without the Access Broker the index daemon needs\n\ + Administrator to start. Run elevated (or install the broker) for a full sweep." + ); + return Ok(SweepDecision::Skip); + } + println!( + "\nA thorough uninstall deep-sweeps every drive for stray UFFS files. Without\n\ + the Access Broker, the index daemon can only start from an elevated process." + ); + let choice = prompt_choice( + "\n d = deep sweep — start the daemon now (Windows shows one UAC prompt)\n\ + \x20 s = skip the deep sweep (standard locations only)\n\ + \n\ + Choice [d/S]: ", + )?; + if matches!(choice.as_str(), "d" | "deep" | "deep sweep") { + Ok(SweepDecision::Proceed { + elevate_daemon: true, + }) + } else { + println!("Deep sweep skipped; only the standard locations are cleaned."); + Ok(SweepDecision::Skip) + } +} + /// Everything the deep-sweep gather produces for the final presentation. /// Windows-only — off Windows the daemon indexes offline captures, not the /// live filesystem, so there is no stray phase at all. @@ -406,10 +477,10 @@ struct GatherOutcome { static GATHER_PHASE: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(0); /// Start the drive-coverage check/reload + deep sweep on a background thread -/// the moment the uninstall fires, so the daemon index work overlaps the -/// elevation question instead of costing wall-clock time after it. Quiet: all -/// narration is deferred into the returned [`GatherOutcome`]. `None` when the -/// sweep is disabled (`--no-deep-sweep`) or running sequentially (`-v`). +/// the moment the sweep decision is made, so the daemon index work overlaps +/// the elevation question instead of costing wall-clock time after it. Quiet: +/// all narration is deferred into the returned [`GatherOutcome`]. `None` when +/// the sweep was skipped at the gate or is running sequentially (`-v`). /// /// The known-dirs snapshot is taken before the elevation gate mutates the /// plan; that is safe because the gate only drops service/process items, which @@ -418,13 +489,19 @@ static GATHER_PHASE: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8 fn start_stray_gather( parsed: &UninstallArgs, removal_plan: &RemovalPlan, + sweep: SweepDecision, ) -> Option> { - if parsed.no_deep_sweep || parsed.verbose { + let SweepDecision::Proceed { elevate_daemon } = sweep else { + return None; + }; + if parsed.verbose { return None; } GATHER_PHASE.store(0, core::sync::atomic::Ordering::Relaxed); let known = plan_dirs(removal_plan); - Some(std::thread::spawn(move || gather_strays(&known, true))) + Some(std::thread::spawn(move || { + gather_strays(&known, true, elevate_daemon) + })) } /// The gather body: ensure drive coverage (quiet = narration deferred), then @@ -432,8 +509,8 @@ fn start_stray_gather( /// under `--dry-run` too — coverage and searching are non-destructive, and a /// dry run should preview the *complete* picture. #[cfg(windows)] -fn gather_strays(known: &[PathBuf], quiet: bool) -> GatherOutcome { - let coverage_notes = coverage::ensure_drive_coverage(quiet); +fn gather_strays(known: &[PathBuf], quiet: bool, elevate_daemon: bool) -> GatherOutcome { + let coverage_notes = coverage::ensure_drive_coverage(quiet, elevate_daemon); GATHER_PHASE.store(1, core::sync::atomic::Ordering::Relaxed); sweep::dbg_gap(); @@ -464,21 +541,22 @@ fn gather_strays(known: &[PathBuf], quiet: bool) -> GatherOutcome { /// Collect the gather results: join the background thread behind a spinner /// (default), run the gather synchronously and loudly (`-v`), or return empty -/// (`--no-deep-sweep`). A panicked gather degrades to "no strays found". +/// (the sweep was skipped at the gate). A panicked gather degrades to "no +/// strays found". #[cfg(windows)] fn finish_stray_gather( - parsed: &UninstallArgs, removal_plan: &RemovalPlan, gather: Option>, + sweep: SweepDecision, ) -> GatherOutcome { if let Some(handle) = gather { spinner_wait(&handle); return handle.join().unwrap_or_default(); } - if parsed.no_deep_sweep { + let SweepDecision::Proceed { elevate_daemon } = sweep else { return GatherOutcome::default(); - } - gather_strays(&plan_dirs(removal_plan), false) + }; + gather_strays(&plan_dirs(removal_plan), false, elevate_daemon) } /// Animate a small spinner on the current line until `handle` finishes, with a From cf7d1f9c0a67ec4d8fbd8c9e10f0a8006f325241 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:49:49 -0700 Subject: [PATCH 33/40] fix(uninstall): tear down a sweep-started daemon (fixes uffsd.exe Access-denied) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-broker deep sweep can START the index daemon (its UAC start) AFTER the removal plan was snapshotted with no daemon running. The plan's shutdown group was therefore built from an empty `report.running` and carried no stop for that daemon, so at teardown nothing stopped it — its (possibly elevated) image stayed locked and the runtime-binary delete failed: FAILED 4 binaries in C:\Users\rnio\bin (C:\Users\rnio\bin\uffsd.exe: Access is denied. (os error 5)) Windows offers no way to retain/reuse the UAC elevation token in the non-elevated parent — the prompt elevated a separate child (the daemon), not us. But we do not need to: the executor already stops the daemon with a graceful shutdown RPC, which needs no caller privilege and so stops even an elevated daemon; once it exits, its user-owned binary deletes fine non-elevated. The only missing piece was a stop item in the plan. After the gather, re-discover the live daemon (running_daemon_pid) and fold it into the plan via RemovalPlan::ensure_daemon_shutdown(pid): prepend a daemon StopProcess to the "Shutdown (stopped last)" group, creating that group in the correct position (before the data / runtime-binary groups) when it does not yet exist. No-op when a daemon stop already exists. Group titles are now shared consts so the find/recreate matches build_plan verbatim. Two regression tests: injection lands the stop before the runtime binaries; an existing stop is not duplicated. cargo clippy (host) + cargo xwin clippy (Windows prod + tests) clean; 126 uffs-cli tests pass. Co-Authored-By: Claude Fable 5 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 19 +++++ .../uffs-cli/src/commands/uninstall/plan.rs | 71 +++++++++++++++-- .../src/commands/uninstall/plan/tests.rs | 78 +++++++++++++++++++ 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 3d00f99f5..58353e665 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -98,6 +98,14 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { // plan, and the gate notes. Nothing was shown while data was in flight. #[cfg(windows)] let gathered = finish_stray_gather(&removal_plan, gather, sweep); + // The deep sweep may have STARTED the daemon (the no-broker UAC start) after + // the plan was snapshotted with none running — make sure the plan stops that + // live daemon before its binary is deleted, or its locked image would fail + // the runtime-binary delete with Access-denied. + #[cfg(windows)] + if let Some(pid) = running_daemon_pid() { + removal_plan.ensure_daemon_shutdown(pid); + } #[cfg(windows)] let stray_plan = &gathered.stray_plan; #[cfg(not(windows))] @@ -559,6 +567,17 @@ fn finish_stray_gather( gather_strays(&plan_dirs(removal_plan), false, elevate_daemon) } +/// The pid of the daemon that is running right now, or `None` if none answers. +/// Used after the gather to fold a sweep-started daemon into the shutdown plan. +#[cfg(windows)] +fn running_daemon_pid() -> Option { + uffs_client::connect_sync::UffsClientSync::connect_raw() + .ok() + .and_then(|mut client| client.status().ok()) + .map(|status| status.pid) + .filter(|&pid| pid != 0) +} + /// Animate a small spinner on the current line until `handle` finishes, with a /// label tracking the gather stage; the line is cleared before returning so /// the presentation starts clean. diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index 8f16b7256..d691916fb 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -24,6 +24,18 @@ use crate::commands::update::model::{Channel, Component, DetectionReport, Instal /// The `WinGet` package id UFFS publishes under. pub(crate) const WINGET_PACKAGE_ID: &str = "SkyLLC.UFFS"; +/// Heading of the shutdown group (daemon stop + broker service removal). Shared +/// so `RemovalPlan::ensure_daemon_shutdown` (Windows-only) can find / recreate +/// it verbatim. +const SHUTDOWN_GROUP_TITLE: &str = "Shutdown (stopped last)"; + +/// Heading of the data / cache / config group (the shutdown group must precede +/// it — a running daemon holds handles inside these dirs). +const DATA_GROUP_TITLE: &str = "Data / cache / config"; + +/// Heading of the runtime-binaries group (deletable only after shutdown). +const RUNTIME_GROUP_TITLE: &str = "Runtime binaries (after shutdown)"; + /// 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. @@ -202,6 +214,54 @@ impl RemovalPlan { dropped } + /// Make sure the plan stops the daemon at `pid` before its binary is + /// deleted. The deep sweep can *start* the daemon (the no-broker path's UAC + /// start) **after** the plan was snapshotted, so `report.running` had none + /// and the shutdown group carries no stop for it — without this the + /// freshly-started, possibly elevated daemon keeps its image locked and the + /// runtime-binary delete fails with Access-denied. No-op when a daemon stop + /// already exists. The executor stops it with a graceful shutdown RPC (no + /// caller elevation needed), so the elevation obtained to *start* it need + /// not be re-acquired to stop it. + #[cfg(windows)] + pub(crate) fn ensure_daemon_shutdown(&mut self, pid: u32) { + let already = self.items().any(|item| { + matches!(&item.target, PlanTarget::StopProcess { component, .. } if component == "daemon") + }); + if already { + return; + } + let stop = PlanItem { + target: PlanTarget::StopProcess { + component: Component::Daemon.label().to_owned(), + pid, + }, + needs_elevation: false, + scope: ItemScope::Any, + bytes: 0, + }; + // Prepend to the existing shutdown group, or create it just before the + // data / runtime-binary groups it must precede (Windows locks the image + // of a running process, so the stop has to run first). + if let Some(group) = self + .groups + .iter_mut() + .find(|group| group.title == SHUTDOWN_GROUP_TITLE) + { + group.items.insert(0, stop); + return; + } + let at = self + .groups + .iter() + .position(|group| group.title == DATA_GROUP_TITLE || group.title == RUNTIME_GROUP_TITLE) + .unwrap_or(self.groups.len()); + self.groups.insert(at, PlanGroup { + title: SHUTDOWN_GROUP_TITLE, + items: vec![stop], + }); + } + /// Number of items across all groups. pub(crate) fn item_count(&self) -> usize { self.groups.iter().map(|group| group.items.len()).sum() @@ -308,7 +368,7 @@ pub(crate) fn build_plan( bytes: 0, }); } - push_group(&mut groups, "Shutdown (stopped last)", shutdown, args.scope); + push_group(&mut groups, SHUTDOWN_GROUP_TITLE, shutdown, args.scope); // 4. Data / cache / config dirs that exist (skip config under // --keep-config). After the daemon shutdown: a running daemon holds open @@ -328,7 +388,7 @@ pub(crate) fn build_plan( bytes: dir.size_bytes, }) .collect(); - push_group(&mut groups, "Data / cache / config", dirs, args.scope); + push_group(&mut groups, DATA_GROUP_TITLE, dirs, args.scope); // 5. Runtime binaries — deletable only now that their processes/services // are stopped (Windows locks a running image). @@ -337,12 +397,7 @@ pub(crate) fn build_plan( .iter() .filter_map(|root| binary_item(root, StemSet::Runtime)) .collect(); - push_group( - &mut groups, - "Runtime binaries (after shutdown)", - runtime, - args.scope, - ); + push_group(&mut groups, RUNTIME_GROUP_TITLE, runtime, args.scope); RemovalPlan { groups } } diff --git a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs index 07d617b6d..ad60f13e0 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs @@ -247,6 +247,84 @@ fn running_process_becomes_a_stop_item() { ))); } +#[cfg(windows)] +#[test] +fn ensure_daemon_shutdown_injects_a_stop_before_the_runtime_binaries() { + // No daemon was running when the plan was built (the deep sweep starts one + // afterwards), so the plan has no daemon stop — but it does have runtime + // binaries whose image the sweep-started daemon would lock. + let report = DetectionReport { + roots: vec![root(Channel::Unmanaged, Scope::User, r"C:\Users\me\bin")], + running: Vec::new(), + }; + let mut plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + assert!( + !has_target(&plan, |target| matches!( + target, + PlanTarget::StopProcess { .. } + )), + "no daemon stop before the injection" + ); + + plan.ensure_daemon_shutdown(9191); + + let stop_group = plan + .groups + .iter() + .position(|group| { + group.items.iter().any(|item| { + matches!(&item.target, PlanTarget::StopProcess { component, pid } + if component == "daemon" && *pid == 9191) + }) + }) + .expect("the daemon stop was injected"); + let runtime_group = plan + .groups + .iter() + .position(|group| group.title == "Runtime binaries (after shutdown)") + .expect("runtime-binaries group present"); + assert!( + stop_group < runtime_group, + "the daemon stop must run before the runtime binaries are deleted" + ); +} + +#[cfg(windows)] +#[test] +fn ensure_daemon_shutdown_is_a_noop_when_a_stop_already_exists() { + let report = DetectionReport { + roots: Vec::new(), + running: vec![RunningProcess { + component: Component::Daemon, + pid: 4242, + image_path: None, + command_line: None, + version: None, + }], + }; + let mut plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + let before = plan.item_count(); + plan.ensure_daemon_shutdown(9191); + assert_eq!( + plan.item_count(), + before, + "an existing daemon stop is not duplicated" + ); + assert!( + plan.items().any(|item| matches!(&item.target, + PlanTarget::StopProcess { pid, .. } if *pid == 4242)), + "the analyzed daemon stop is kept (not replaced)" + ); +} + #[test] fn drop_elevation_required_removes_broker_keeps_the_rest() { let report = DetectionReport { From 9672a6e85b507f9c889032a52664f9d061a9fbd5 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:10:08 -0700 Subject: [PATCH 34/40] fix(uninstall): silence the thin-client auto-start chatter during the quiet sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deep-sweep coverage reload (kill + start) runs on a background thread under a spinner, but the freshly-restarted daemon's start-wait went through the thin client's auto-start connect loop, whose "[uffs] connect attempt N/M (socket: missing)" retry line prints straight to stderr — a layer below the CLI's QUIET flag — and bled onto the spinner: ⠼ Gathering artifacts (indexing the drives ...) [uffs] connect attempt 1/20 (socket: missing) Give uffs-client its own suppression toggle (set_quiet_autostart) gating that eprintln, and drive it from daemon_quiet alongside the existing CLI QUIET flag (QuietGuard restores both on drop, so it never sticks past the reload). The spinner line now stays clean. cargo clippy (host) + cargo xwin clippy (Windows) clean; 332 tests pass. Co-Authored-By: Claude Fable 5 --- crates/uffs-cli/src/commands/daemon_mgmt.rs | 8 +++++++ crates/uffs-client/src/connect_sync.rs | 23 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/uffs-cli/src/commands/daemon_mgmt.rs b/crates/uffs-cli/src/commands/daemon_mgmt.rs index 975323e29..adcf6b066 100644 --- a/crates/uffs-cli/src/commands/daemon_mgmt.rs +++ b/crates/uffs-cli/src/commands/daemon_mgmt.rs @@ -33,6 +33,9 @@ struct QuietGuard; impl Drop for QuietGuard { fn drop(&mut self) { QUIET.store(false, core::sync::atomic::Ordering::Relaxed); + // Restore the thin client's auto-start retry chatter (a quiet reload may + // have (re)started the daemon, driving that connect loop). + uffs_client::connect_sync::set_quiet_autostart(false); } } @@ -47,6 +50,11 @@ impl Drop for QuietGuard { /// Exactly [`daemon`]'s errors. pub(crate) fn daemon_quiet(action: &DaemonAction) -> Result<()> { QUIET.store(true, core::sync::atomic::Ordering::Relaxed); + // Also silence the thin client's own auto-start retry chatter, which prints + // straight to stderr from a layer below this flag (the QuietGuard restores + // it). Otherwise a background reload's "[uffs] connect attempt …" bleeds + // onto the caller's spinner line. + uffs_client::connect_sync::set_quiet_autostart(true); let _guard = QuietGuard; daemon(action) } diff --git a/crates/uffs-client/src/connect_sync.rs b/crates/uffs-client/src/connect_sync.rs index 956233a64..b88d1c93a 100644 --- a/crates/uffs-client/src/connect_sync.rs +++ b/crates/uffs-client/src/connect_sync.rs @@ -14,6 +14,7 @@ //! | macOS/Linux | `std::os::unix::net::UnixStream` | //! | Windows | Named pipe via `std::fs::OpenOptions` (no Winsock) | +use core::sync::atomic::{AtomicBool, Ordering}; use std::io::{BufRead as _, BufReader, Read, Write}; use crate::connect_sync_autostart::auto_start_daemon; @@ -22,6 +23,26 @@ use crate::daemon_spawn::{ElevationPolicy, resolve_elevation_policy}; use crate::error::ClientError; use crate::protocol::response::DaemonStatus; +/// When set, the auto-start connect loop suppresses its user-facing +/// `[uffs] connect attempt …` retry chatter (default off). A caller driving a +/// daemon (re)start *behind its own progress UI* — the uninstall deep-sweep +/// coverage reload, which runs under a spinner on another thread — sets this so +/// the retry lines do not garble that display. Process-wide, best set via a +/// scoped guard on the caller's side so it never sticks. +static QUIET_AUTOSTART: AtomicBool = AtomicBool::new(false); + +/// Suppress (or restore) the auto-start connect retry chatter (the +/// `[uffs] connect attempt …` lines). The caller owns balancing this back to +/// `false` — best via a scoped guard so it never sticks. +pub fn set_quiet_autostart(quiet: bool) { + QUIET_AUTOSTART.store(quiet, Ordering::Relaxed); +} + +/// Whether the auto-start retry chatter is currently suppressed. +fn quiet_autostart() -> bool { + QUIET_AUTOSTART.load(Ordering::Relaxed) +} + /// Synchronous thin client for the UFFS daemon. /// /// One request, one response, no event loop. Phase 3b decisions: @@ -304,7 +325,7 @@ impl UffsClientSync { // Log sparingly — eprintln is intentional user-facing output // during daemon auto-start retries (no tracing in thin client). - if attempt <= 3 || attempt == max_attempts { + if !quiet_autostart() && (attempt <= 3 || attempt == max_attempts) { #[expect( clippy::print_stderr, reason = "intentional user-facing retry progress" From f9da590b9519a401f2c6699699f41f13543c8f2e Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:58:28 -0700 Subject: [PATCH 35/40] feat(uninstall): count binary sizes in the reclaim total (no more "~0 B") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binary-delete plan items carried bytes: 0, so a run removing only binaries + daemon + runtime (no data/cache dirs present) reported "Reclaims ~0 B across 3 CORE item(s)" while permanently deleting ~22 real binaries. Add RemovalPlan::size_binaries(size_of): the pure plan module stays IO-free and takes a caller-supplied sizer; analyze_and_plan (the IO layer that already pulls dir sizes from the inventory) stats each stem's file (uffsd -> uffsd.exe on Windows, best-effort — an absent file contributes 0) and folds the totals in. WinGet delegations and dir/process items are untouched. exe_file_name is now pub(crate) so the sizer reuses the executor's naming. Regression test: size_binaries fills only DeleteBinaries items and leaves the inventory's dir sizes alone. cargo clippy (host) + cargo xwin clippy (Windows, prod + tests) clean; rustdoc -Dwarnings clean; 127 uffs-cli tests pass. Co-Authored-By: Claude Fable 5 --- .../src/commands/uninstall/effects.rs | 2 +- crates/uffs-cli/src/commands/uninstall/mod.rs | 18 ++++++++- .../uffs-cli/src/commands/uninstall/plan.rs | 15 +++++++ .../src/commands/uninstall/plan/tests.rs | 40 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index 473eee843..38e07195d 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -239,7 +239,7 @@ pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> { } /// The on-disk file name for a binary stem (`uffsd` -> `uffsd.exe` on Windows). -fn exe_file_name(stem: &str) -> String { +pub(crate) fn exe_file_name(stem: &str) -> String { #[cfg(windows)] { format!("{stem}.exe") diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 58353e665..db0c90ec4 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -245,10 +245,26 @@ fn analyze_and_plan( let resolved = resolve_order::group_and_resolve(&candidates, &analyze::search_dirs()); let inventory = inventory::collect(); let removable_path = analyze::removable_path_dirs(&report, &analyze::path_entries()); - let removal_plan = plan::build_plan(&report, &inventory, parsed, &removable_path); + let mut removal_plan = plan::build_plan(&report, &inventory, parsed, &removable_path); + // Fold each binary's on-disk size into the plan (statting is IO the pure + // plan module leaves to us), so the "Reclaims ~N" line counts the binaries, + // not just the data dirs. + removal_plan.size_binaries(binary_dir_bytes); (resolved, inventory, removal_plan) } +/// Best-effort total on-disk size of the named binary stems inside `dir` +/// (`uffsd` -> `uffsd.exe` on Windows). An absent / unreadable file contributes +/// 0 — sizing must never fail the plan. +fn binary_dir_bytes(dir: &std::path::Path, stems: &[String]) -> u64 { + stems + .iter() + .map(|stem| { + std::fs::metadata(dir.join(effects::exe_file_name(stem))).map_or(0, |meta| meta.len()) + }) + .fold(0, u64::saturating_add) +} + /// What the elevation gate decided for this run. enum ElevationChoice { /// Elevated, `--dry-run`, or nothing needs Administrator — plan untouched. diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index d691916fb..90b0e4f40 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -214,6 +214,21 @@ impl RemovalPlan { dropped } + /// Fill in the reclaim bytes of every binary-delete item, so the summary's + /// "Reclaims ~N" reflects the binaries too (not just the data dirs). + /// Statting files is IO, which this pure module leaves to the caller: + /// `size_of` maps a `(dir, stems)` binary-delete target to its on-disk + /// total (best-effort — an absent file contributes 0). `WinGet` + /// delegations and directory / process items are untouched (winget owns + /// its bytes; dir sizes already came from the inventory). + pub(crate) fn size_binaries(&mut self, size_of: impl Fn(&Path, &[String]) -> u64) { + for item in self.groups.iter_mut().flat_map(|group| &mut group.items) { + if let PlanTarget::DeleteBinaries { dir, stems } = &item.target { + item.bytes = size_of(dir, stems); + } + } + } + /// Make sure the plan stops the daemon at `pid` before its binary is /// deleted. The deep sweep can *start* the daemon (the no-broker path's UAC /// start) **after** the plan was snapshotted, so `report.running` had none diff --git a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs index ad60f13e0..e062336b8 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs @@ -247,6 +247,46 @@ fn running_process_becomes_a_stop_item() { ))); } +#[test] +fn size_binaries_fills_only_binary_delete_items_from_the_sizer() { + // A plan with a deletable binaries root plus a data dir (already sized) and + // a PATH item (never sized). + let report = DetectionReport { + roots: vec![root(Channel::Unmanaged, Scope::User, r"C:\Users\me\bin")], + running: Vec::new(), + }; + let mut plan = built( + &report, + &inventory(BrokerServiceState::Absent, 1024), + &UninstallArgs::default(), + ); + let dirs_before = plan + .items() + .filter(|item| matches!(item.target, PlanTarget::DeleteDir { .. })) + .map(|item| item.bytes) + .sum::(); + + // Sizer reports 4096 bytes for any binary target. + plan.size_binaries(|_dir, stems| 4096 * stems.len() as u64); + + let binary_bytes = plan + .items() + .filter(|item| matches!(item.target, PlanTarget::DeleteBinaries { .. })) + .map(|item| item.bytes) + .sum::(); + assert_eq!(binary_bytes, 4096, "the single `uffs` stem was sized"); + // The data-dir bytes are untouched by size_binaries. + let dirs_after = plan + .items() + .filter(|item| matches!(item.target, PlanTarget::DeleteDir { .. })) + .map(|item| item.bytes) + .sum::(); + assert_eq!( + dirs_after, dirs_before, + "dir sizes are left as the inventory set them" + ); +} + #[cfg(windows)] #[test] fn ensure_daemon_shutdown_injects_a_stop_before_the_runtime_binaries() { From 05e718d29f53af16a3bf1d624c3da80c5f152df4 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:08:57 -0700 Subject: [PATCH 36/40] docs(ci): record why the rustdoc gate omits private_intra_doc_links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We hit this question live: -Dwarnings + --document-private-items reports 0 warnings, but bolting on -D rustdoc::private_intra_doc_links flips 2 valid internal //! links in uffs-cli/src/main.rs to errors. That looks like a gap and invites someone to "harden" the flag in — but the current posture is the desired one. Pin the rationale in the rustdoc gate's notes so it is not flipped by accident: --document-private-items documents the internals, so a public/crate-root link to a pub(crate) sibling is a valid internal cross-reference, not a downstream leak; private_intra_doc_links guards a *published* crate's public docs against dangling to items a consumer cannot see, which does not apply to our internal doc build. Enabling it would demote valid internal [symbol] links to dead code spans for zero correctness gain. The real failure class (broken / unresolved links) is already caught by broken-intra-doc-links under -Dwarnings. Notes-only manifest edit: gates-drift, hooks-drift, workflow-drift all clean (notes do not feed generated hooks/workflows). Co-Authored-By: Claude Fable 5 --- scripts/ci/gates.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/ci/gates.toml b/scripts/ci/gates.toml index 9f21378f1..152aa6da4 100644 --- a/scripts/ci/gates.toml +++ b/scripts/ci/gates.toml @@ -440,6 +440,22 @@ only links reachable from the public API surface, so a broken sibling) silently renders as dead text instead of failing. Cross- platform — `#[cfg(windows)]` items absent on the macOS/Linux runner are written as code spans, not links. + +INTENTIONAL: we do NOT add `-D rustdoc::private_intra_doc_links`, and +this is the desired posture — do not "harden" it in. `-Dwarnings` +already denies the whole default warning set; that lint stays quiet +here because `--document-private-items` documents the internals, so a +`//!`/public link to a `pub(crate)` sibling is a *valid internal +cross-reference*, not a leak. (Verified: `-Dwarnings` + +`--document-private-items` = 0 warnings; adding an explicit +`-D rustdoc::private_intra_doc_links` flips 2 such links in +`uffs-cli/src/main.rs` to errors.) That lint guards a *published* +crate's public docs from dangling to items a downstream user cannot +see — a concern that does not apply to our internal-only doc build. +Turning it on would force valid internal `[symbol]` links down to +dead code spans (a doc regression) for zero correctness gain. The +real failure class we care about — broken / unresolved links anywhere +— is already caught by `broken-intra-doc-links` under `-Dwarnings`. """ [[gate]] From 9249b9899e0d39e8855650909220a66d97f8e35e Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:54:35 -0700 Subject: [PATCH 37/40] fix(uninstall): flatten the removal plan + coherent decline wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX fixes from live Windows runs: 1. The consent surface split binaries into "Binaries" and "Runtime binaries (after shutdown)" (17 + 4 in the same folder), plus group headers like "Shutdown (stopped last)" — the internal teardown-ordering detail leaked to the user, who just wants "21 binaries in ". print_plan now coalesces every DeleteBinaries item per directory into one line, drops the group headings, folds EXTRA into the same numbered list, and simplifies the reclaim footer ("Reclaims ~121.2 MB." / "…, plus N file(s) removed only with ALL."). The plan's internal group order is unchanged — this is presentation only, so the teardown still stops the daemon before deleting its locked image. 2. When the no-broker sweep's elevated daemon start was declined at the UAC prompt, the note read: "Note: the index daemon was reloaded (kill + start) ..." " could not start the daemon: Failed to start daemon (with elevation)" — it claimed success then contradicted itself. The optimistic "reloaded" note was pushed up front, before the start was even attempted. It is now pushed ONLY after start succeeds; a failed start emits one coherent note that names the likely cause ("the UAC prompt was likely declined") and reassures the sweep continues on the drives already indexed. cargo clippy (host) + cargo xwin clippy (Windows, prod + tests) clean; rustdoc -Dwarnings clean; 127 uffs-cli tests pass. Co-Authored-By: Claude Fable 5 --- .../src/commands/uninstall/coverage.rs | 50 +++++++---- .../uffs-cli/src/commands/uninstall/render.rs | 85 +++++++++++++------ 2 files changed, 95 insertions(+), 40 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs index 39c536cf4..b4e27ce49 100644 --- a/crates/uffs-cli/src/commands/uninstall/coverage.rs +++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs @@ -111,12 +111,11 @@ fn reload_daemon_for_coverage( .map(ToString::to_string) .collect::>() .join(", "); - if quiet { - notes.push(format!( - "\nNote: the index daemon was reloaded (kill + start) to cover every drive\n\ - for the deep sweep (it was missing {list})." - )); - } else { + // Loud mode announces the attempt live; quiet mode stays silent until the + // OUTCOME is known — a pre-declared "reloaded" note would contradict a later + // start failure (e.g. a declined UAC prompt), which is exactly what the user + // saw. The truthful note is pushed only once `start` actually succeeds. + if !quiet { emit( quiet, notes, @@ -134,8 +133,8 @@ fn reload_daemon_for_coverage( quiet, notes, format!( - " could not kill the daemon: {err}\n\ - Continuing the deep sweep with whatever is loaded." + "\nNote: could not stop the running daemon ({err}).\n\ + The deep sweep will scan the drives already indexed." ), ); return; @@ -143,17 +142,18 @@ fn reload_daemon_for_coverage( wait_until_daemon_down(); if let Err(err) = run_handler(quiet, &start_action(elevate_daemon)) { - emit( - quiet, - notes, - format!( - " could not start the daemon: {err}\n\ - Continuing the deep sweep with whatever is loaded." - ), - ); + emit(quiet, notes, start_failure_note(elevate_daemon, &err)); return; } + // Success: the daemon is back with full coverage, so the note is truthful. + if quiet { + notes.push(format!( + "\nNote: the index daemon was restarted to cover every drive for the deep\n\ + sweep (it was missing {list})." + )); + } + let managed = current_managed_drives(); let covered = all.iter().filter(|drive| managed.contains(drive)).count(); if covered < all.len() { @@ -168,6 +168,24 @@ fn reload_daemon_for_coverage( } } +/// A coherent note for a failed coverage start — the elevated no-broker case +/// names the likely cause (a declined UAC prompt) so the message does not read +/// as a bug. Never claims the daemon "was reloaded" (it was not). +fn start_failure_note(elevate_daemon: bool, err: &anyhow::Error) -> String { + if elevate_daemon { + format!( + "\nNote: the elevated index daemon a full deep sweep needs could not be\n\ + started (the UAC prompt was likely declined: {err}).\n\ + The deep sweep will scan the drives already indexed." + ) + } else { + format!( + "\nNote: the index daemon could not be started ({err}).\n\ + The deep sweep will scan the drives already indexed." + ) + } +} + /// Dispatch `action` through the CLI handlers — the silenced variant in quiet /// mode so background work never prints over the interactive prompt. fn run_handler(quiet: bool, action: &DaemonAction) -> anyhow::Result<()> { diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index c3517105f..876065567 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -5,10 +5,12 @@ //! resolution table + the artifact inventory, in human form and as `--json`. //! The removal plan is layered on in later milestones. +use std::path::{Path, PathBuf}; + use serde_json::{Value, json}; use super::inventory::Inventory; -use super::plan::RemovalPlan; +use super::plan::{PlanTarget, RemovalPlan}; use super::remove::{ItemStatus, RemovalOutcome}; use super::resolve_order::{ResolutionState, StemResolution}; #[cfg(windows)] @@ -162,49 +164,84 @@ pub(crate) fn print_plan(plan: &RemovalPlan, extra: &RemovalPlan) { println!("\nNothing to remove: no UFFS install or artifacts were found."); return; } - println!("\nThe following will be PERMANENTLY removed (no recovery):"); + println!("\nThe following will be PERMANENTLY removed (no recovery):\n"); 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 { - "" - }; + let mut shown_binary_dirs: Vec = Vec::new(); + for item in plan.items() { + // Coalesce all binary deletes for one directory into a single + // "N binaries in " line. The internal tools-vs-runtime split (and + // its group headings) is a teardown-ordering detail — the user just + // wants to know how many binaries in which folder go away. + if let PlanTarget::DeleteBinaries { dir, .. } = &item.target { + if shown_binary_dirs.iter().any(|shown| shown == dir) { + continue; + } + shown_binary_dirs.push(dir.clone()); + let (count, needs_admin) = binary_dir_totals(plan, dir); println!( - " [{index}] {desc}{elevated}", - desc = item.target.describe() + " [{index}] {count} binaries in {}{}", + dir.display(), + admin_flag(needs_admin), ); index = index.saturating_add(1); + continue; } + println!( + " [{index}] {desc}{elevated}", + desc = item.target.describe(), + elevated = admin_flag(item.needs_elevation), + ); + index = index.saturating_add(1); } // The EXTRA files ride the same summary so nothing is hidden from the // final picture, but they are a separate choice: the ALL/CORE question. if !extra.is_empty() { - println!("\n Found elsewhere (EXTRA)"); println!( - " [{index}] {count} UFFS file(s) outside the standard install locations \ - (listed above; removed only with ALL)", + " [{index}] {count} file(s) found elsewhere (removed only with ALL)", count = extra.item_count(), ); } if extra.is_empty() { - println!( - "\nReclaims ~{} across {} item(s).", - human_bytes(plan.total_bytes()), - plan.item_count(), - ); + println!("\nReclaims ~{}.", human_bytes(plan.total_bytes())); } else { println!( - "\nReclaims ~{} across {} CORE item(s), plus {} EXTRA file(s) with ALL.", + "\nReclaims ~{}, plus {} file(s) removed only with ALL.", human_bytes(plan.total_bytes()), - plan.item_count(), extra.item_count(), ); } } +/// The ` (needs Administrator)` suffix, or empty when the item is removable +/// as the current user. +const fn admin_flag(needs_elevation: bool) -> &'static str { + if needs_elevation { + " (needs Administrator)" + } else { + "" + } +} + +/// Sum the binary stems across every `DeleteBinaries` item targeting `dir` (the +/// tools and runtime passes land in separate groups), and whether any of them +/// needs Administrator. Used to fold the split into one consent line. +fn binary_dir_totals(plan: &RemovalPlan, dir: &Path) -> (usize, bool) { + let mut count: usize = 0; + let mut needs_admin = false; + for item in plan.items() { + if let PlanTarget::DeleteBinaries { + dir: item_dir, + stems, + } = &item.target + && item_dir == dir + { + count = count.saturating_add(stems.len()); + needs_admin |= item.needs_elevation; + } + } + (count, needs_admin) +} + /// The up-front elevation gate (U-30): the FIRST thing a non-elevated run says. /// Explains which items need an Administrator terminal and why, before any /// analysis output — the question that follows is the only elevation decision. @@ -343,7 +380,7 @@ pub(crate) fn print_journal_warning(error: &anyhow::Error) { /// Note that the running self-binaries are deferred to a post-exit delete /// (the OS locks a running image, so they can't be removed in place). #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_self_delete_scheduled(paths: &[std::path::PathBuf]) { +pub(crate) fn print_self_delete_scheduled(paths: &[PathBuf]) { println!("\nThe running UFFS binary is removed after this process exits:"); for path in paths { println!(" {}", path.display()); @@ -361,7 +398,7 @@ pub(crate) fn print_self_delete_warning(error: &anyhow::Error) { /// 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]) { +pub(crate) fn print_verification(remaining: &[PathBuf]) { if remaining.is_empty() { println!("\nVerified: all targeted UFFS locations are gone."); return; From c509307762af9a65dd91fa52fc48ae980ead730c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:25:29 -0700 Subject: [PATCH 38/40] fix(uninstall): leave the broker cleanly when elevation is declined + honest verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the broker-installed, non-elevated `e` (elevate-at-removal) path — the one path never exercised before now. 1. Declining the UAC prompt used to attempt-and-fail the dependent broker work: Removal finished: 2 removed, 2 failed. FAILED Stop + delete service UffsAccessBroker (UAC declined ...) FAILED 4 binaries in C:\Users\rnio\bin (uffs-broker.exe: Access is denied ...) The second failure is a *consequence* of the first — the still-running broker locks its own image. Now the declined UAC is a typed signal (ElevationDeclined) the executor recognises: it records the service as LEFT (not FAILED), stops attempting the doomed broker binary (deletes the other runtime binaries, leaves uffs-broker.exe), and prints ONE clear next step. New ItemStatus::Skipped distinguishes "deliberately left" from "failed to remove". 2. The final line claimed "Verified: all targeted UFFS locations are gone" while the broker service + binary were still there (they are not among the stat-checked paths). The upbeat claim is now gated on a clean run (nothing failed, nothing left); a declined broker no longer reads as success. 3. The self-delete note listed uffs.exe + uffs-update.exe by full path — a mechanism detail. Collapsed to one line: "The uffs command removes itself once this process exits." Also: one blank line between the sweep-decision prompt and the gather spinner (it butted right up against "Choice [d/S]: d"). New unit test: a declined elevation leaves the broker service + binary as two LEFT items with zero hard failures, and never attempts the broker image while still removing the other runtime binaries. cargo clippy (host) + cargo xwin clippy (Windows, prod + tests) clean; rustdoc -Dwarnings clean; 128 tests pass. Co-Authored-By: Claude Fable 5 --- .../src/commands/uninstall/effects.rs | 8 +- crates/uffs-cli/src/commands/uninstall/mod.rs | 7 +- .../uffs-cli/src/commands/uninstall/remove.rs | 200 +++++++++++++++++- .../uffs-cli/src/commands/uninstall/render.rs | 65 +++--- 4 files changed, 238 insertions(+), 42 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs index 38e07195d..65cfccb0d 100644 --- a/crates/uffs-cli/src/commands/uninstall/effects.rs +++ b/crates/uffs-cli/src/commands/uninstall/effects.rs @@ -398,10 +398,10 @@ fn remove_service_via_uac(service: &str) -> Result<()> { } Ok(()) } - Some(UAC_NOT_GRANTED_EXIT) => bail!( - "elevation was not granted (UAC declined) — {service} was left installed; \ - re-run `uffs --uninstall` from an Administrator terminal to remove it" - ), + // Typed so the executor recognises the decline and LEAVES the broker + // (service + its locked binary) as a clean outcome, instead of the raw + // Access-denied that deleting the still-running broker's image produces. + Some(UAC_NOT_GRANTED_EXIT) => Err(super::remove::ElevationDeclined.into()), other => bail!( "elevated service-removal helper failed (exit {other:?}) — {service} may still \ be installed" diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index db0c90ec4..c1e43026b 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -201,7 +201,7 @@ fn execute_all( // M8 self-delete (U-80): finish the deferred delete of the running // self-binaries the executor skipped. If even scheduling fails, say so. if !self_paths.is_empty() { - render::print_self_delete_scheduled(&self_paths); + render::print_self_delete_scheduled(); if let Err(err) = effects::schedule_self_delete(&self_paths) { render::print_self_delete_warning(&err); } @@ -217,7 +217,7 @@ fn execute_all( .any(|self_path| self_path.starts_with(dir)) }) .collect(); - render::print_verification(&verify::still_present(&to_check)); + render::print_verification(&verify::still_present(&to_check), outcome.is_clean()); // M9: clear the in-progress marker now the run finished. if let Err(err) = journal::finish() { @@ -603,6 +603,9 @@ fn spinner_wait(handle: &std::thread::JoinHandle) { use std::io::Write as _; const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + // One blank line between the sweep-decision prompt and the spinner, so the + // gather does not butt right up against "Choice [d/S]: d". + println!(); let mut frame = 0_usize; while !handle.is_finished() { let label = if GATHER_PHASE.load(core::sync::atomic::Ordering::Relaxed) == 0 { diff --git a/crates/uffs-cli/src/commands/uninstall/remove.rs b/crates/uffs-cli/src/commands/uninstall/remove.rs index 60337b152..70bbe4b07 100644 --- a/crates/uffs-cli/src/commands/uninstall/remove.rs +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -16,9 +16,38 @@ use std::path::Path; use anyhow::Result; -use super::plan::{PlanTarget, RemovalPlan}; +use super::plan::{PlanItem, PlanTarget, RemovalPlan}; use crate::commands::update::model::Scope; +/// Marker error: the elevation an item needed was declined at the UAC prompt. +/// The executor recognises it (via downcast) and LEAVES the Access Broker — +/// service plus its still-locked binary — as a clean "left" outcome, instead of +/// attempting-and-failing each with a raw Access-denied. +#[derive(Debug)] +pub(crate) struct ElevationDeclined; + +impl core::fmt::Display for ElevationDeclined { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("elevation was declined at the UAC prompt") + } +} + +impl core::error::Error for ElevationDeclined {} + +/// Reason recorded when the broker service is left because elevation was +/// declined at the UAC prompt. +const BROKER_SERVICE_LEFT: &str = "the Access Broker (a LocalSystem service) needs Administrator"; + +/// Reason recorded when the broker binary is left: its service is still +/// running, so the image is locked and cannot be deleted without stopping the +/// service. +const BROKER_BINARY_LEFT: &str = "the Access Broker service is still running"; + +/// Whether `stem` names the broker binary (locked while its service runs). +const fn is_broker_stem(stem: &str) -> bool { + stem.eq_ignore_ascii_case("uffs-broker") +} + /// 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. @@ -47,6 +76,10 @@ pub(crate) enum ItemStatus { Done, /// The item failed; carries the error text. Failed(String), + /// The item was deliberately left in place — not a failure to fix, but a + /// consequence of a choice (elevation declined at the UAC prompt, so the + /// broker and its locked binary stay). Carries the plain-language reason. + Skipped(String), } /// The result of executing a whole plan: one entry per item, in order. @@ -90,25 +123,105 @@ impl RemovalOutcome { .count() } - /// Whether every item completed. - pub(crate) fn all_done(&self) -> bool { - self.failed_count() == 0 + /// Number of items deliberately left in place (e.g. the broker after a + /// declined elevation). + pub(crate) fn skipped_count(&self) -> usize { + self.results + .iter() + .filter(|(_, status)| matches!(status, ItemStatus::Skipped(_))) + .count() + } + + /// Whether the run removed everything it set out to — nothing failed and + /// nothing was left behind. Gates the "all gone" verification claim. + pub(crate) fn is_clean(&self) -> bool { + self.failed_count() == 0 && self.skipped_count() == 0 } } /// Execute `plan` in order against `effects`, recording each item's outcome. -/// Best-effort: a failing item is recorded and the walk continues. +/// Best-effort: a failing item is recorded and the walk continues. Once the +/// broker's elevation is declined, dependent broker work is *left* (recorded as +/// [`ItemStatus::Skipped`]) rather than attempted — no point deleting a binary +/// the still-running service holds locked. pub(crate) fn execute(plan: &RemovalPlan, effects: &mut dyn Effects) -> RemovalOutcome { let mut outcome = RemovalOutcome::default(); + let mut elevation_declined = false; for item in plan.items() { - let description = item.target.describe(); - let status = match dispatch(&item.target, effects) { + run_item(item, effects, &mut elevation_declined, &mut outcome); + } + outcome +} + +/// Execute one plan item, folding its result into `outcome`. Sets +/// `elevation_declined` when the broker service removal hits a declined UAC +/// prompt, so later broker-dependent items are left rather than fought. +fn run_item( + item: &PlanItem, + effects: &mut dyn Effects, + elevation_declined: &mut bool, + outcome: &mut RemovalOutcome, +) { + let description = item.target.describe(); + if let PlanTarget::RemoveService { service } = &item.target { + match effects.remove_service(service) { + Ok(()) => outcome.record(description, ItemStatus::Done), + Err(err) if err.downcast_ref::().is_some() => { + *elevation_declined = true; + outcome.record( + description, + ItemStatus::Skipped(BROKER_SERVICE_LEFT.to_owned()), + ); + } + Err(err) => outcome.record(description, ItemStatus::Failed(format!("{err:#}"))), + } + return; + } + // Broker's removal was declined, so its service still runs and locks + // uffs-broker.exe: delete the other runtime binaries, leave the broker's. + if let PlanTarget::DeleteBinaries { dir, stems } = &item.target + && *elevation_declined + && stems.iter().any(|stem| is_broker_stem(stem)) + { + delete_binaries_leaving_broker(dir, stems, effects, outcome); + return; + } + let status = match dispatch(&item.target, effects) { + Ok(()) => ItemStatus::Done, + Err(err) => ItemStatus::Failed(format!("{err:#}")), + }; + outcome.record(description, status); +} + +/// Delete every runtime binary in `dir` EXCEPT the broker's (whose service is +/// still running): the deletable ones are removed as one item, the broker +/// binary is recorded as left — a clean outcome, not an Access-denied failure. +fn delete_binaries_leaving_broker( + dir: &Path, + stems: &[String], + effects: &mut dyn Effects, + outcome: &mut RemovalOutcome, +) { + let (broker, rest): (Vec, Vec) = + stems.iter().cloned().partition(|stem| is_broker_stem(stem)); + if !rest.is_empty() { + let description = format!("{} binaries in {}", rest.len(), dir.display()); + let status = match effects.delete_binaries(dir, &rest) { Ok(()) => ItemStatus::Done, Err(err) => ItemStatus::Failed(format!("{err:#}")), }; outcome.record(description, status); } - outcome + for stem in broker { + outcome.record( + format!( + "{} in {}", + super::effects::exe_file_name(&stem), + dir.display() + ), + ItemStatus::Skipped(BROKER_BINARY_LEFT.to_owned()), + ); + } } /// Route one target to the matching [`Effects`] call. @@ -150,6 +263,9 @@ mod tests { struct RecordingEffects { calls: Vec, fail_marker: Option, + /// When set, `remove_service` returns [`super::ElevationDeclined`], as + /// a declined UAC prompt does. + decline_service: bool, } impl Effects for RecordingEffects { @@ -159,6 +275,9 @@ mod tests { } fn remove_service(&mut self, service: &str) -> Result<()> { self.calls.push(format!("remove_service:{service}")); + if self.decline_service { + return Err(super::ElevationDeclined.into()); + } Ok(()) } fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> { @@ -235,10 +354,71 @@ mod tests { "stop_process:daemon:7".to_owned(), "remove_dir:/x/cache".to_owned(), ]); - assert!(outcome.all_done()); + assert!(outcome.is_clean()); assert_eq!(outcome.done_count(), 3); } + /// A plan with the broker service installed + a root holding the broker + /// binary alongside another runtime binary, so the decline path has both a + /// service to leave and a broker image to leave. + fn broker_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: ["uffsd", "uffs-broker"] + .into_iter() + .map(|name| BinaryInfo { + name: name.to_owned(), + version: None, + }) + .collect(), + }], + running: Vec::new(), + }; + let inventory = Inventory { + dirs: Vec::new(), + broker_service: BrokerServiceState::Installed, + }; + build_plan(&report, &inventory, &UninstallArgs::default(), &[]) + } + + #[test] + fn declined_elevation_leaves_the_broker_service_and_binary_not_fails_them() { + let plan = broker_plan(); + let mut effects = RecordingEffects { + decline_service: true, + ..RecordingEffects::default() + }; + let outcome = execute(&plan, &mut effects); + + // The broker binary is never even attempted (its service still runs); + // only the service removal + the OTHER runtime binary were called. + assert!( + !effects + .calls + .iter() + .any(|call| call.contains("uffs-broker")), + "the broker binary delete must not be attempted: {:?}", + effects.calls + ); + // Two items LEFT (the service + the broker binary), zero hard failures. + assert_eq!(outcome.skipped_count(), 2, "service + broker binary left"); + assert_eq!(outcome.failed_count(), 0, "nothing is a hard failure"); + assert!(!outcome.is_clean(), "leftovers mean the run is not clean"); + // The deletable runtime binary (uffsd) still went through as one item. + assert!( + effects + .calls + .iter() + .any(|call| call == "delete_binaries:/opt/uffs:1"), + "the non-broker runtime binary is still removed: {:?}", + effects.calls + ); + } + #[test] fn a_failing_item_is_recorded_and_the_rest_continue() { let plan = full_plan(); @@ -251,7 +431,7 @@ mod tests { assert_eq!(effects.calls.len(), 3); assert_eq!(outcome.failed_count(), 1); assert_eq!(outcome.done_count(), 2); - assert!(!outcome.all_done()); + assert!(!outcome.is_clean()); let failed = outcome .results .iter() diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 876065567..fc6489fc2 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -377,14 +377,12 @@ pub(crate) fn print_journal_warning(error: &anyhow::Error) { eprintln!("note: uninstall progress marker could not be updated ({error:#})."); } -/// Note that the running self-binaries are deferred to a post-exit delete -/// (the OS locks a running image, so they can't be removed in place). +/// Note that the running `uffs` binary finishes removing itself after the +/// process exits (the OS locks a running image). One quiet line — the exact +/// paths are a mechanism detail the user does not need. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_self_delete_scheduled(paths: &[PathBuf]) { - println!("\nThe running UFFS binary is removed after this process exits:"); - for path in paths { - println!(" {}", path.display()); - } +pub(crate) fn print_self_delete_scheduled() { + println!("\nThe uffs command removes itself once this process exits."); } /// Warn that the running self-binary could not be scheduled for deletion. @@ -396,11 +394,17 @@ pub(crate) fn print_self_delete_warning(error: &anyhow::Error) { ); } -/// Print the post-removal verification: clean, or the locations that survived. +/// Print the post-removal verification. The upbeat "all gone" is claimed only +/// when the run was `clean` — nothing failed and nothing was left (a declined +/// broker removal is NOT "all gone", even though the leftover service/binary +/// are not among the stat-checked `remaining` paths). `print_outcome` already +/// explained any leftovers, so this stays quiet in that case. #[expect(clippy::print_stdout, reason = "CLI user-facing output")] -pub(crate) fn print_verification(remaining: &[PathBuf]) { +pub(crate) fn print_verification(remaining: &[PathBuf], clean: bool) { if remaining.is_empty() { - println!("\nVerified: all targeted UFFS locations are gone."); + if clean { + println!("\nVerified: all targeted UFFS locations are gone."); + } return; } println!( @@ -413,29 +417,38 @@ pub(crate) fn print_verification(remaining: &[PathBuf]) { } } -/// Print the outcome of a removal run: counts, any failures, and a retry hint. +/// Print the outcome of a removal run: counts, any failures / left items, and +/// the matching next-step 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(), - ); + let failed = outcome.failed_count(); + let skipped = outcome.skipped_count(); + let mut parts = vec![format!("{} removed", outcome.done_count())]; + if failed > 0 { + parts.push(format!("{failed} failed")); + } + if skipped > 0 { + parts.push(format!("{skipped} left")); + } + println!("\nRemoval finished: {}.", parts.join(", ")); + for (description, status) in &outcome.results { - if let ItemStatus::Failed(error) = status { - println!(" FAILED {description} ({error})"); + match status { + ItemStatus::Failed(error) => println!(" FAILED {description} ({error})"), + ItemStatus::Skipped(reason) => println!(" LEFT {description} ({reason})"), + ItemStatus::Done => {} } } - if !outcome.all_done() { - // Elevation only exists on Windows (the broker is a LocalSystem service); - // every non-Windows uninstall runs entirely in user-land, so a failure - // there is a file in use, never a privilege problem — no sudo hint. - #[cfg(windows)] + + // Left items are always the broker after a declined elevation (Windows-only): + // one clear next step, not the generic file-in-use hint. + if skipped > 0 { println!( - "\nSome items could not be removed — e.g. the broker, a LocalSystem service. \ - Re-run `uffs --uninstall` from an elevated (Administrator) terminal." + "\nThe Access Broker was left because elevation was declined. Re-run\n\ + `uffs --uninstall` from an Administrator terminal to remove it." ); - #[cfg(not(windows))] + } + if failed > 0 { println!( "\nSome items could not be removed (a file may be in use). Close anything \ using them and re-run." From 45b3032d9d667aeb6e66074bb49dfefe09d9bc11 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:32:15 -0700 Subject: [PATCH 39/40] fix(uninstall): also leave the broker binary on the non-elevated "continue" path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix cleaned the `e` (elevate-at-removal) decline, but the `c` (continue-without-elevation) path still hit the same wall: the elevation gate drops the broker SERVICE item up front, yet the broker BINARY stayed in the plan and failed with a raw Access-denied because the left-behind service keeps uffs-broker.exe locked: NOT removed in this run (needs Administrator): - Stop + delete service UffsAccessBroker <- clean, up front ... Removal finished: 4 removed, 1 failed. FAILED 4 binaries in C:\Users\rnio\bin (uffs-broker.exe: Access is denied) Generalise the decline handling into one condition: the broker binary is LEFT whenever the broker service REMAINS — computed up front (broker installed AND the plan carries no RemoveService item, i.e. the `c` path dropped it) and also flipped by an in-plan declined UAC (the `e` path). execute() takes a `broker_remains` flag seeded from that, so both paths leave uffs-broker.exe cleanly (recorded LEFT) while still deleting the other runtime binaries. New test: broker_remains=true up front leaves only the broker binary, makes no remove_service call, and still removes the non-broker runtime binary. cargo clippy (host) + cargo xwin clippy (Windows, prod + tests) clean; rustdoc -Dwarnings clean; 129 tests pass. Co-Authored-By: Claude Fable 5 --- crates/uffs-cli/src/commands/uninstall/mod.rs | 17 ++- .../uffs-cli/src/commands/uninstall/remove.rs | 103 +++++++++++++++--- 2 files changed, 101 insertions(+), 19 deletions(-) diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index c1e43026b..53e3b0969 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -147,11 +147,22 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { return Ok(()); } let remove_strays = matches!(choice, FinalChoice::All) && !stray_plan.is_empty(); + // The broker service stays installed whenever the plan won't remove it (the + // non-elevated "continue without" choice dropped it), so its binary is + // locked and must be LEFT, not fought. A declined UAC on the `e` path is + // detected during execution. + let broker_remains = matches!( + inventory.broker_service, + inventory::BrokerServiceState::Installed + ) && !removal_plan + .items() + .any(|item| matches!(item.target, PlanTarget::RemoveService { .. })); execute_all( &removal_plan, stray_plan, remove_strays, matches!(gate, ElevationChoice::ElevateAtRemoval), + broker_remains, ); Ok(()) } @@ -167,6 +178,7 @@ fn execute_all( stray_plan: &RemovalPlan, remove_strays: bool, elevate_via_uac: bool, + broker_remains: bool, ) { // M9: mark the run in progress (survives the lifecycle-dir deletion) so an // interruption is detectable next launch. Best-effort: a failed marker write @@ -186,10 +198,11 @@ fn execute_all( let mut effects = effects::SystemEffects::new(self_paths.clone(), elevate_via_uac); let mut outcome = remove::RemovalOutcome::default(); if !removal_plan.is_empty() { - outcome.absorb(remove::execute(removal_plan, &mut effects)); + outcome.absorb(remove::execute(removal_plan, &mut effects, broker_remains)); } if remove_strays { - outcome.absorb(remove::execute(stray_plan, &mut effects)); + // Strays are loose files, never the broker service's binary. + outcome.absorb(remove::execute(stray_plan, &mut effects, false)); } if !outcome.is_empty() { render::print_outcome(&outcome); diff --git a/crates/uffs-cli/src/commands/uninstall/remove.rs b/crates/uffs-cli/src/commands/uninstall/remove.rs index 70bbe4b07..b992ce328 100644 --- a/crates/uffs-cli/src/commands/uninstall/remove.rs +++ b/crates/uffs-cli/src/commands/uninstall/remove.rs @@ -140,26 +140,34 @@ impl RemovalOutcome { } /// Execute `plan` in order against `effects`, recording each item's outcome. -/// Best-effort: a failing item is recorded and the walk continues. Once the -/// broker's elevation is declined, dependent broker work is *left* (recorded as -/// [`ItemStatus::Skipped`]) rather than attempted — no point deleting a binary -/// the still-running service holds locked. -pub(crate) fn execute(plan: &RemovalPlan, effects: &mut dyn Effects) -> RemovalOutcome { +/// Best-effort: a failing item is recorded and the walk continues. +/// +/// `broker_remains` is `true` when the Access Broker service will still be +/// installed after this run — the non-elevated "continue without" choice drops +/// the service item up front — so its binary is locked from the start and is +/// *left* rather than fought. It also flips `true` if an in-plan service +/// removal is declined at the UAC prompt. Either way the broker's binary is +/// recorded as [`ItemStatus::Skipped`], never a raw Access-denied failure. +pub(crate) fn execute( + plan: &RemovalPlan, + effects: &mut dyn Effects, + broker_remains: bool, +) -> RemovalOutcome { let mut outcome = RemovalOutcome::default(); - let mut elevation_declined = false; + let mut remains = broker_remains; for item in plan.items() { - run_item(item, effects, &mut elevation_declined, &mut outcome); + run_item(item, effects, &mut remains, &mut outcome); } outcome } /// Execute one plan item, folding its result into `outcome`. Sets -/// `elevation_declined` when the broker service removal hits a declined UAC -/// prompt, so later broker-dependent items are left rather than fought. +/// `broker_remains` when an in-plan broker service removal is declined at the +/// UAC prompt, so the later broker binary is left rather than fought. fn run_item( item: &PlanItem, effects: &mut dyn Effects, - elevation_declined: &mut bool, + broker_remains: &mut bool, outcome: &mut RemovalOutcome, ) { let description = item.target.describe(); @@ -167,7 +175,7 @@ fn run_item( match effects.remove_service(service) { Ok(()) => outcome.record(description, ItemStatus::Done), Err(err) if err.downcast_ref::().is_some() => { - *elevation_declined = true; + *broker_remains = true; outcome.record( description, ItemStatus::Skipped(BROKER_SERVICE_LEFT.to_owned()), @@ -177,10 +185,11 @@ fn run_item( } return; } - // Broker's removal was declined, so its service still runs and locks - // uffs-broker.exe: delete the other runtime binaries, leave the broker's. + // The broker service is staying (declined, or the non-elevated run left it), + // so it still runs and locks uffs-broker.exe: delete the other runtime + // binaries, leave the broker's alongside its service. if let PlanTarget::DeleteBinaries { dir, stems } = &item.target - && *elevation_declined + && *broker_remains && stems.iter().any(|stem| is_broker_stem(stem)) { delete_binaries_leaving_broker(dir, stems, effects, outcome); @@ -345,7 +354,7 @@ mod tests { fn executes_every_item_in_group_order() { let plan = full_plan(); let mut effects = RecordingEffects::default(); - let outcome = execute(&plan, &mut effects); + let outcome = execute(&plan, &mut effects, false); // Teardown-last ordering: tool binaries first (the tooling stays // usable during the run), then the daemon shutdown, then the data // dirs it had open handles in. @@ -392,7 +401,8 @@ mod tests { decline_service: true, ..RecordingEffects::default() }; - let outcome = execute(&plan, &mut effects); + // Broker is in the plan (an `e` run); the declined UAC flips the flag. + let outcome = execute(&plan, &mut effects, false); // The broker binary is never even attempted (its service still runs); // only the service removal + the OTHER runtime binary were called. @@ -419,6 +429,65 @@ mod tests { ); } + #[test] + fn broker_remains_leaves_the_broker_binary_up_front() { + // The `c` path leaves the broker: the gate dropped the service item, so + // the plan has NO RemoveService (modelled here with the service absent) + // and `broker_remains` is true from the start. The broker binary is then + // left without any remove_service call — no Access-denied. + let report = DetectionReport { + roots: vec![InstallRoot { + dir: PathBuf::from("/opt/uffs"), + channel: Channel::Unmanaged, + scope: Scope::User, + anchored_by: Vec::new(), + binaries: ["uffsd", "uffs-broker"] + .into_iter() + .map(|name| BinaryInfo { + name: name.to_owned(), + version: None, + }) + .collect(), + }], + running: Vec::new(), + }; + let inventory = Inventory { + dirs: Vec::new(), + broker_service: BrokerServiceState::Absent, + }; + let plan = build_plan(&report, &inventory, &UninstallArgs::default(), &[]); + let mut effects = RecordingEffects::default(); + let outcome = execute(&plan, &mut effects, true); + + assert!( + !effects + .calls + .iter() + .any(|call| call.contains("remove_service")), + "no service removal is attempted: {:?}", + effects.calls + ); + assert!( + !effects + .calls + .iter() + .any(|call| call.contains("uffs-broker")), + "the locked broker binary is not attempted: {:?}", + effects.calls + ); + assert_eq!(outcome.skipped_count(), 1, "just the broker binary is left"); + assert_eq!(outcome.failed_count(), 0, "no Access-denied failure"); + // uffsd still deleted (the non-broker runtime binary). + assert!( + effects + .calls + .iter() + .any(|call| call == "delete_binaries:/opt/uffs:1"), + "the non-broker runtime binary is still removed: {:?}", + effects.calls + ); + } + #[test] fn a_failing_item_is_recorded_and_the_rest_continue() { let plan = full_plan(); @@ -426,7 +495,7 @@ mod tests { fail_marker: Some("/x/cache".to_owned()), ..RecordingEffects::default() }; - let outcome = execute(&plan, &mut effects); + let outcome = execute(&plan, &mut effects, false); // All three were attempted; the cache dir failed, the other two done. assert_eq!(effects.calls.len(), 3); assert_eq!(outcome.failed_count(), 1); From 69232f1591b48780cd231387838c757fcc44938d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:35:11 -0700 Subject: [PATCH 40/40] chore(security): risk-accept the two quick-xml advisories (unreachable, no fix exists) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUSTSEC-2026-0195 (unbounded namespace-declaration allocation) and RUSTSEC-2026-0194 (quadratic duplicate-attribute check) both landed against quick-xml <0.41 and turned every CI run red, kicking #502 out of the merge queue. Why an ignore and not a fix: there is nothing to bump to. quick-xml is transitive-only (polars-io -> object_store 0.13 -> quick-xml ^0.39); the newest object_store on crates.io (0.14.0) still requires quick-xml ^0.40.1, below the fixed 0.41, and we are already on the latest polars (0.54.4). The vulnerable paths are object_store's cloud-store XML LIST parsing — UFFS reads the local NTFS MFT and local index files only, and never opens a cloud object path, so the NsReader code is unreachable in every UFFS binary. Both entries carry the removal condition in-line: drop them when object_store ships a quick-xml >=0.41 release AND polars adopts it. Validated locally: `cargo deny check advisories` -> ok. Co-Authored-By: Claude Fable 5 --- deny.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/deny.toml b/deny.toml index 0295d9287..65622b475 100644 --- a/deny.toml +++ b/deny.toml @@ -20,6 +20,22 @@ ignore = [ # No action required from our side - Polars team will handle migration # See: https://rustsec.org/advisories/RUSTSEC-2025-0141 "RUSTSEC-2025-0141", + # quick-xml <0.41: unbounded namespace-declaration allocation in NsReader (DoS on + # untrusted XML). Transitive-only: polars-io -> object_store 0.13 -> quick-xml ^0.39. + # NOT reachable in UFFS: the vulnerable path is object_store's cloud-store XML LIST + # parsing; UFFS only reads the local NTFS MFT and local index files, never a cloud + # object path. No upstream fix exists to bump to as of 2026-07-02 — the newest + # object_store (0.14.0) still requires quick-xml ^0.40.1 (< the fixed 0.41), and we + # are already on the latest polars (0.54.4). Remove this ignore when object_store + # ships a quick-xml >=0.41 release AND polars adopts it. + # See: https://rustsec.org/advisories/RUSTSEC-2026-0195 + "RUSTSEC-2026-0195", + # Same crate, same path, same reasoning as RUSTSEC-2026-0195 above: quick-xml <0.41 + # quadratic run time checking a start tag for duplicate attribute names (DoS on + # untrusted XML). Unreachable in UFFS (no cloud object paths); no upstream fix + # reachable yet. Remove together with the ignore above. + # See: https://rustsec.org/advisories/RUSTSEC-2026-0194 + "RUSTSEC-2026-0194", ] [licenses]