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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] =?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/36] =?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/36] 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/36] 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/36] 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/36] 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 dde03056e773ef89e348d77582e608a4acab42db Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:39:12 -0700 Subject: [PATCH 32/36] chore(deps): bump rmcp 2.1.0, aes-gcm 0.11.0, indicatif 0.18.6, rand 0.10.2 + full cargo-vet coverage Version bumps in workspace Cargo.toml: rmcp 1.8.0 -> 2.1.0 (MCP 2025-11-25 spec), aes-gcm 0.10 -> 0.11.0, rand 0.10.1 -> 0.10.2, indicatif 0.18.4 -> 0.18.6, plus the transitive lockfile fallout. Supply-chain: 17 newly-unvetted crates cleared with real audits and publisher trust, no exemption bumps. Ten RustCrypto crates (aead, aes, cipher, cmov, cpubits, ctr, ctutils, ghash, polyval, universal-hash) are covered by trusted-publisher entries for the RustCrypto GitHub org accounts, consistent with the existing digest/hybrid-array precedent. Six deltas were reviewed line-by-line and certified (notes in supply-chain/audits.toml). cargo vet prune dropped the superseded exemptions; cargo vet --locked passes. Vet-Reviewed-Diff: rmcp@1.8.0->2.1.0 Vet-Reviewed-Diff: rmcp-macros@1.8.0->2.1.0 Vet-Reviewed-Diff: aes-gcm@0.10.3->0.11.0 Vet-Reviewed-Diff: rand@0.10.1->0.10.2 Vet-Reviewed-Diff: console@0.16.3->0.16.4 Vet-Reviewed-Diff: indicatif@0.18.4->0.18.6 Co-Authored-By: Claude Fable 5 --- Cargo.lock | 128 ++++++++++++++++++++------------------ Cargo.toml | 8 +-- supply-chain/audits.toml | 96 ++++++++++++++++++++++++++++ supply-chain/config.toml | 20 ------ supply-chain/imports.lock | 89 ++++++++++++++++---------- 5 files changed, 222 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8016b4cb..4dd7d1c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,30 +10,30 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.1.7", - "generic-array", + "crypto-common 0.2.2", + "inout", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpubits", + "cpufeatures 0.3.0", ] [[package]] name = "aes-gcm" -version = "0.10.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" dependencies = [ "aead", "aes", @@ -594,11 +594,12 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.1.7", + "block-buffer 0.12.0", + "crypto-common 0.2.2", "inout", ] @@ -645,6 +646,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -697,9 +704,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -735,6 +742,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -876,7 +889,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -886,18 +898,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", + "rand_core 0.10.1", ] [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "cty" version = "0.2.2" @@ -1313,11 +1336,10 @@ dependencies = [ [[package]] name = "ghash" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "opaque-debug", "polyval", ] @@ -1726,9 +1748,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -1739,11 +1761,11 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -2096,7 +2118,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml", - "rand 0.10.1", + "rand 0.10.2", "reqwest", "ring", "serde", @@ -2129,12 +2151,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2912,13 +2928,12 @@ dependencies = [ [[package]] name = "polyval" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", + "cpubits", + "cpufeatures 0.3.0", "universal-hash", ] @@ -3131,9 +3146,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3160,15 +3175,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -3380,9 +3386,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.8.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +checksum = "f00a32c3b81b7b254076a65abd5ab2551209146713ba38f73818657e865e9433" dependencies = [ "async-trait", "base64", @@ -3394,7 +3400,7 @@ dependencies = [ "http-body-util", "pastey", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "rmcp-macros", "schemars", "serde", @@ -3411,9 +3417,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.8.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +checksum = "2ee70afb7956da9f30d5348a2539b5eb90d9038f834463657ab717076ac3b1ad" dependencies = [ "darling", "proc-macro2", @@ -4508,7 +4514,7 @@ dependencies = [ "libmimalloc-sys", "mimalloc", "proptest", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "tempfile", @@ -4627,7 +4633,7 @@ dependencies = [ "indicatif", "libc", "proptest", - "rand 0.10.1", + "rand 0.10.2", "rand_chacha 0.10.0", "rayon", "rustc-hash", @@ -4665,7 +4671,7 @@ dependencies = [ "dirs-next", "libc", "memmap2", - "rand 0.10.1", + "rand 0.10.2", "security-framework", "tempfile", "tracing", @@ -4771,12 +4777,12 @@ checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.1.7", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f307ab35b..523183c5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -235,7 +235,7 @@ thiserror = "2.0.18" anyhow = "1.0.103" # ───── MCP (Model Context Protocol) ───── -rmcp = { version = "1.8.0", features = ["server", "transport-io", "macros"] } +rmcp = { version = "2.1.0", features = ["server", "transport-io", "macros"] } schemars = "1.2.1" # ───── HTTP / Tower (for MCP Streamable HTTP gateway) ───── @@ -258,7 +258,7 @@ clap = { version = "4.6.1", features = [ "unicode", "wrap_help", ] } -indicatif = "0.18.4" +indicatif = "0.18.6" devicons = "0.6.12" # ANSI terminal colouring. Used by the `uffs-ci-pipeline` tool; kept at # workspace scope so a future UI surface that wants colored output inherits @@ -328,8 +328,8 @@ num_cpus = "1.17.0" uuid = { version = "1.23.4", features = ["v4"] } # ───── Security / Crypto ───── -aes-gcm = "0.10" -rand = "0.10.1" +aes-gcm = "0.11.0" +rand = "0.10.2" rand_chacha = "0.10.0" security-framework = "3.7.0" diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml index c50853ca9..c9025c9f9 100644 --- a/supply-chain/audits.toml +++ b/supply-chain/audits.toml @@ -1,6 +1,12 @@ # cargo-vet audits file +[[audits.aes-gcm]] +who = "Robert M1 <50460704+githubrobbi@users.noreply.github.com>" +criteria = "safe-to-deploy" +delta = "0.10.3 -> 0.11.0" +notes = "Delta audit (cargo vet diff 0.10.3 -> 0.11.0). Single-file crate; src change is only src/lib.rs (rest is docs/tests/Wycheproof vectors). Changes are the RustCrypto 2024-editions migration: aead 0.6 (AeadInPlace -> AeadInOut with InOutBuf), cipher 0.5 + hybrid-array (GenericArray -> Array). Crypto flow is IDENTICAL: init_ctr -> Ctr32BE keystream + GHASH compute_tag; decrypt still verifies the tag via subtle::ConstantTimeEq BEFORE applying the keystream. Length limits corrected to NIST SP 800-38D exactly (P_MAX 2^36 -> 2^36-32 bytes, i.e. tightened; A_MAX 2^36 -> 2^61-1 per spec). New: hazmat feature gates U4/U8 short tags with an SP 800-38D usage warning; manual Debug impl via finish_non_exhaustive leaks no key material. grep confirms ZERO unsafe in 0.11.0 src (crate was previously deny(unsafe_code), still none). No fs/net/process/env surface. Publisher tarcieri (Tony Arcieri, lead RustCrypto maintainer, same publisher as 0.10.3)." + [[audits.anyhow]] who = "Robert M1 <50460704+githubrobbi@users.noreply.github.com>" criteria = "safe-to-deploy" @@ -55,6 +61,12 @@ criteria = "safe-to-deploy" delta = "0.9.0 -> 0.9.1" notes = """Delta audit (cargo vet diff 0.9.0 -> 0.9.1, 3 src files). src/lib.rs: CompactString::repeat now computes capacity with self.len().checked_mul(n).expect("capacity overflow") instead of an unchecked multiply — an overflow-safety hardening. src/repr/heap.rs: realloc uses Capacity::new(new_capacity) and adds a regression test (test_realloc_shrink_to_min_heap_gap) covering the shrink-to-MIN_HEAP_SIZE boundary. src/tests.rs additions are test-only. No NEW unsafe blocks (the crate's existing inline-repr unsafe is unchanged), no new deps, no new ambient capability. Publisher ParkMyCar (compact_str maintainer).""" +[[audits.console]] +who = "Robert M1 <50460704+githubrobbi@users.noreply.github.com>" +criteria = "safe-to-deploy" +delta = "0.16.3 -> 0.16.4" +notes = "Delta audit (cargo vet diff 0.16.3 -> 0.16.4, 435-line diff). (1) term.rs: TermInner construction deduplicated into new()/new_buffered()/with_buffer() helpers - pure refactor; new pub fn is_dumb() reads TERM env var (read-only env access, same capability class the crate already had via is_a_color_terminal). (2) utils.rs: Style::from_dotted_str gains is_ascii() guards on '#RRGGBB'/'on_#RRGGBB' parsing - fixes a panic on non-ASCII input that slices mid-UTF-8 char (robustness improvement for untrusted style strings), plus regression tests. (3) windows_term: as_handle() helper removed in favor of out.as_raw_handle() at existing call sites - unchanged unsafe surface (same SetConsoleCursorPosition/SetConsoleCursorInfo calls as before); read_secure backspace arm converted to a match guard, logic identical. No new unsafe blocks, no new fs/net/process capability. Publisher djc (Dirkjan Ochtman, console-rs maintainer, same publisher as 0.16.3)." + [[audits.crypto-common]] who = "Robert Nio " criteria = "safe-to-deploy" @@ -79,6 +91,12 @@ criteria = "safe-to-deploy" version = "0.5.2" notes = "Reviewed v0.5.2 source. Transitive dep of num_cpus. Two files: errno.rs is pure i32 constants (EPERM, ENOENT, ...); lib.rs is #![no_std] FFI declarations for the Hermit unikernel syscall interface (sys_mmap, sys_getpagesize, sys_errno, thread scheduling primitives, ...) plus two unsafe wrapper fns for get/set_priority. No network I/O, no filesystem I/O, no std dependency. On non-Hermit targets the extern C symbols are never linked and the functions are inert — num_cpus only touches hermit-abi when target_os=hermit, which none of our shipping targets hit. Apache-2.0 OR MIT; author Stefan Lankes, Hermit OS project lead." +[[audits.indicatif]] +who = "Robert M1 <50460704+githubrobbi@users.noreply.github.com>" +criteria = "safe-to-deploy" +delta = "0.18.4 -> 0.18.6" +notes = "Delta audit (cargo vet diff 0.18.4 -> 0.18.6, 1547-line diff; src changes limited to draw_target.rs, format.rs, iter.rs, multi.rs, style.rs - rest is examples/tests/CI). All rendering/formatting logic: (1) draw_target.rs switches hidden-detection to console::is_dumb() (pairs with the console 0.16.4 bump) and adds CJK-aware wrapped_metrics (line-height + last-line-width accounting via AnsiCodeIterator + UnicodeWidthChar); (2) format.rs fixes HumanFloatCount negative-sign grouping ('-,100' bug) and precision-0 rounding, uses stable div_duration_f64; (3) iter.rs converts map-and-return closures to Result::inspect - behavior identical; (4) multi.rs replaces is_hidden()/width() forwarders with direct pub(crate) draw_target field access + big doc addition, adds Multi arm to is_stderr(); (5) style.rs moves segment/measure/width helpers verbatim and converts Template::from_str to the FromStr trait. Zero unsafe, zero fs/net/process/env additions. Publisher djc, same as 0.18.4." + [[audits.libmimalloc-sys]] who = "Robert Nio " criteria = "safe-to-deploy" @@ -295,6 +313,12 @@ criteria = "safe-to-deploy" delta = "0.39.2 -> 0.39.4" notes = "Delta audit (cargo vet diff 0.39.2 -> 0.39.4, 3 files +35/-10). Cargo.toml/Cargo.toml.orig: version bump only. src/parser/dtd.rs is the only source change — three robustness fixes to the DTD internal-subset parser, all panic-prevention: (a) when 9+ bytes already accumulated in UndecidedMarkup state without matching one of