diff --git a/Cargo.lock b/Cargo.lock
index 70b804226..b8016b4cb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4384,6 +4384,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"toml",
+ "winresource",
]
[[package]]
@@ -4397,6 +4398,7 @@ dependencies = [
"uffs-security",
"uffs-winsvc",
"windows 0.62.2",
+ "winresource",
]
[[package]]
@@ -4421,6 +4423,7 @@ dependencies = [
"serde_json",
"tokio",
"uuid",
+ "winresource",
]
[[package]]
@@ -4522,6 +4525,7 @@ dependencies = [
"uffs-mft",
"uffs-security",
"windows 0.62.2",
+ "winresource",
]
[[package]]
@@ -4535,6 +4539,7 @@ dependencies = [
"sha2 0.11.0",
"uffs-mft",
"uffs-polars",
+ "winresource",
]
[[package]]
@@ -4556,6 +4561,7 @@ dependencies = [
"clap",
"serde",
"toml",
+ "winresource",
]
[[package]]
@@ -4567,6 +4573,7 @@ dependencies = [
"regex",
"serde",
"toml",
+ "winresource",
]
[[package]]
@@ -4577,6 +4584,7 @@ dependencies = [
"clap",
"serde",
"toml",
+ "winresource",
]
[[package]]
@@ -4599,6 +4607,7 @@ dependencies = [
"uffs-client",
"uffs-mft",
"uffs-security",
+ "winresource",
]
[[package]]
@@ -4636,6 +4645,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-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-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-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/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 ─────────────────────────────────────────────
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..adcf6b066 100644
--- a/crates/uffs-cli/src/commands/daemon_mgmt.rs
+++ b/crates/uffs-cli/src/commands/daemon_mgmt.rs
@@ -1,15 +1,63 @@
// 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.
+struct QuietGuard;
+
+impl Drop for QuietGuard {
+ fn drop(&mut self) {
+ QUIET.store(false, core::sync::atomic::Ordering::Relaxed);
+ // Restore the thin client's auto-start retry chatter (a quiet reload may
+ // have (re)started the daemon, driving that connect loop).
+ uffs_client::connect_sync::set_quiet_autostart(false);
+ }
+}
+
+/// 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.
+pub(crate) fn daemon_quiet(action: &DaemonAction) -> Result<()> {
+ QUIET.store(true, core::sync::atomic::Ordering::Relaxed);
+ // Also silence the thin client's own auto-start retry chatter, which prints
+ // straight to stderr from a layer below this flag (the QuietGuard restores
+ // it). Otherwise a background reload's "[uffs] connect attempt …" bleeds
+ // onto the caller's spinner line.
+ uffs_client::connect_sync::set_quiet_autostart(true);
+ let _guard = QuietGuard;
+ daemon(action)
+}
/// Execute a daemon management action.
///
@@ -98,8 +146,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();
@@ -153,18 +201,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)))]
@@ -190,7 +271,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(());
}
@@ -270,8 +353,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:?}"
);
@@ -290,7 +375,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 +394,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);
- }
- }
- 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.");
+ if !is_quiet() {
+ println!("Daemon started and ready.");
}
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 +431,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/analyze.rs b/crates/uffs-cli/src/commands/uninstall/analyze.rs
index c8d725e59..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 /
@@ -77,7 +95,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 +168,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/uninstall/args.rs b/crates/uffs-cli/src/commands/uninstall/args.rs
index 69175def1..1f6aa8fb9 100644
--- a/crates/uffs-cli/src/commands/uninstall/args.rs
+++ b/crates/uffs-cli/src/commands/uninstall/args.rs
@@ -56,10 +56,18 @@ 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.
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 {
@@ -80,6 +88,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
@@ -87,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)?;
@@ -124,6 +139,7 @@ mod tests {
"--no-deep-sweep",
"--no-path",
"--json",
+ "--verbose",
])
.unwrap();
assert!(
@@ -133,7 +149,25 @@ 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 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]
diff --git a/crates/uffs-cli/src/commands/uninstall/coverage.rs b/crates/uffs-cli/src/commands/uninstall/coverage.rs
index 6874aa4a9..b4e27ce49 100644
--- a/crates/uffs-cli/src/commands/uninstall/coverage.rs
+++ b/crates/uffs-cli/src/commands/uninstall/coverage.rs
@@ -3,73 +3,234 @@
//! 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.
+//! 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`, 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).
//!
-//! Best-effort throughout: any RPC failure leaves coverage as-is and the sweep
-//! proceeds against whatever is currently indexed.
+//! 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).
+//!
+//! Best-effort throughout: any failure leaves coverage as-is and the sweep
+//! proceeds against whatever is currently loaded.
#![cfg(windows)]
-use anyhow::Result;
+use core::time::Duration;
+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. 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);
-
-/// 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.
-///
-/// # 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<()> {
+use crate::args::DaemonAction;
+use crate::commands::daemon_mgmt;
+
+/// 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);
+
+/// Whether the daemon already covers every NTFS drive — a cheap RPC check used
+/// by the sweep-elevation decision *before* the gather starts (a daemon with
+/// full coverage needs no reload, elevated or not).
+pub(crate) fn coverage_complete() -> bool {
+ let all = detect_ntfs_drives();
+ if all.is_empty() {
+ return true;
+ }
+ let managed = current_managed_drives();
+ all.iter().all(|drive| managed.contains(drive))
+}
+
+/// Ensure the daemon covers every NTFS drive before the deep sweep. No-op when
+/// coverage is already complete; otherwise reload the daemon (kill + start)
+/// via the real CLI handlers — with `elevate_daemon` the start requests a UAC
+/// prompt (the user opted in at the sweep gate: without the Access Broker a
+/// daemon can only read the MFT elevated). Returns the deferred narration
+/// notes (always empty in loud mode, where everything printed live).
+/// Best-effort: any failure just means the sweep covers whatever is loaded.
+pub(crate) fn ensure_drive_coverage(quiet: bool, elevate_daemon: bool) -> Vec {
+ let mut notes: Vec = Vec::new();
let all = detect_ntfs_drives();
if all.is_empty() {
- return Ok(());
+ return notes;
}
- // `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(());
- };
- 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() {
- return Ok(());
+ // The daemon already covers every system drive — nothing to do.
+ return notes;
}
+ reload_daemon_for_coverage(&all, &missing, quiet, elevate_daemon, &mut notes);
+ notes
+}
+
+/// 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))
+}
+
+/// 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(),
+ )
+}
+
+/// Reload the daemon so it covers every drive: `kill`, wait for it to exit,
+/// 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,
+ elevate_daemon: bool,
+ notes: &mut Vec,
+) {
let list = missing
.iter()
- .map(|drive| format!("{drive}:"))
+ .map(ToString::to_string)
.collect::>()
.join(", ");
- let prompt = format!(
- "\nThe deep sweep searches every indexed drive. Not yet indexed: {list}.\n\
- Index {list} now for a complete sweep? [y/N] "
- );
- if confirm(&prompt)? && client.load_drive_letters(&missing, false).is_ok() {
- // Give the freshly-requested drives a chance to load before we search.
- let _ready = client.await_ready(INDEX_WAIT);
+ // Loud mode announces the attempt live; quiet mode stays silent until the
+ // OUTCOME is known — a pre-declared "reloaded" note would contradict a later
+ // start failure (e.g. a declined UAC prompt), which is exactly what the user
+ // saw. The truthful note is pushed only once `start` actually succeeds.
+ if !quiet {
+ emit(
+ quiet,
+ notes,
+ 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!(
+ "\nNote: could not stop the running daemon ({err}).\n\
+ The deep sweep will scan the drives already indexed."
+ ),
+ );
+ return;
+ }
+ wait_until_daemon_down();
+
+ if let Err(err) = run_handler(quiet, &start_action(elevate_daemon)) {
+ emit(quiet, notes, start_failure_note(elevate_daemon, &err));
+ return;
+ }
+
+ // Success: the daemon is back with full coverage, so the note is truthful.
+ if quiet {
+ notes.push(format!(
+ "\nNote: the index daemon was restarted to cover every drive for the deep\n\
+ sweep (it was missing {list})."
+ ));
+ }
+
+ let managed = current_managed_drives();
+ let covered = all.iter().filter(|drive| managed.contains(drive)).count();
+ if covered < all.len() {
+ emit(
+ quiet,
+ notes,
+ format!(
+ " daemon covers {covered} of {total} drive(s); the deep sweep will scan those.",
+ total = all.len(),
+ ),
+ );
+ }
+}
+
+/// A coherent note for a failed coverage start — the elevated no-broker case
+/// names the likely cause (a declined UAC prompt) so the message does not read
+/// as a bug. Never claims the daemon "was reloaded" (it was not).
+fn start_failure_note(elevate_daemon: bool, err: &anyhow::Error) -> String {
+ if elevate_daemon {
+ format!(
+ "\nNote: the elevated index daemon a full deep sweep needs could not be\n\
+ started (the UAC prompt was likely declined: {err}).\n\
+ The deep sweep will scan the drives already indexed."
+ )
+ } else {
+ format!(
+ "\nNote: the index daemon could not be started ({err}).\n\
+ The deep sweep will scan the drives already indexed."
+ )
+ }
+}
+
+/// Dispatch `action` through the CLI handlers — the silenced variant in quiet
+/// mode so background work never prints over the interactive prompt.
+fn run_handler(quiet: bool, action: &DaemonAction) -> anyhow::Result<()> {
+ 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}");
+ }
+}
+
+/// The [`DaemonAction::Start`] a bare `uffs --daemon start` produces: auto-
+/// discover every NTFS drive, use the cache, default logging. `elevate`
+/// requests the UAC prompt (`--daemon start --elevate`) for the no-broker
+/// sweep path the user opted into.
+fn start_action(elevate: bool) -> DaemonAction {
+ DaemonAction::Start {
+ mft_file: Vec::new(),
+ data_dir: None,
+ drives: Vec::new(),
+ no_cache: false,
+ log_level: "info".to_owned(),
+ log_file: None,
+ elevate,
+ }
+}
+
+/// 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);
}
- Ok(())
}
diff --git a/crates/uffs-cli/src/commands/uninstall/effects.rs b/crates/uffs-cli/src/commands/uninstall/effects.rs
index d550f5ac3..65cfccb0d 100644
--- a/crates/uffs-cli/src/commands/uninstall/effects.rs
+++ b/crates/uffs-cli/src/commands/uninstall/effects.rs
@@ -17,32 +17,118 @@ 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,
+ /// 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.
- 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`]) 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,
+ /// 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))
}
}
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:
+ // graceful shutdown RPC first — it needs no OS privileges, so it also
+ // stops an ELEVATED daemon (the no-broker sweep's UAC start) that
+ // taskkill could not touch — then the `uffs --daemon kill` handler
+ // (pid-file/socket discovery), then the recorded pid as a last resort.
+ // Finally wait for the process to actually exit so its image is
+ // unlocked before the runtime binaries are deleted.
+ if component == "daemon" {
+ let stopped = uffs_client::connect_sync::UffsClientSync::connect_raw()
+ .is_ok_and(|mut client| client.shutdown().is_ok());
+ if !stopped
+ && crate::commands::daemon_mgmt::daemon_quiet(&crate::args::DaemonAction::Kill)
+ .is_err()
+ {
+ terminate_pid(pid)?;
+ }
+ wait_daemon_down();
+ return Ok(());
+ }
terminate_pid(pid)
}
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)
}
fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> {
- for stem in stems {
- let path = dir.join(exe_file_name(stem));
- remove_file_if_present(&path)
- .with_context(|| format!("removing {}", path.display()))?;
+ // 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.
+ .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()));
+ }
+ }
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ bail!(
+ "could not remove {} of {} file(s): {}",
+ errors.len(),
+ stems.len(),
+ errors.join("; ")
+ )
}
- Ok(())
}
fn delegate_winget(&mut self, package_id: &str, scope: Scope) -> Result<()> {
@@ -51,6 +137,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()))
}
@@ -109,6 +199,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(());
}
@@ -122,8 +214,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()
@@ -142,7 +239,7 @@ pub(crate) fn schedule_self_delete(paths: &[PathBuf]) -> Result<()> {
}
/// The on-disk file name for a binary stem (`uffsd` -> `uffsd.exe` on Windows).
-fn exe_file_name(stem: &str) -> String {
+pub(crate) fn exe_file_name(stem: &str) -> String {
#[cfg(windows)]
{
format!("{stem}.exe")
@@ -194,6 +291,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();
@@ -217,9 +329,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) {
@@ -235,10 +349,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(())
+ }
+ // Typed so the executor recognises the decline and LEAVES the broker
+ // (service + its locked binary) as a clean outcome, instead of the raw
+ // Access-denied that deleting the still-running broker's image produces.
+ Some(UAC_NOT_GRANTED_EXIT) => Err(super::remove::ElevationDeclined.into()),
+ other => bail!(
+ "elevated service-removal helper failed (exit {other:?}) — {service} may still \
+ be installed"
+ ),
+ }
+}
+
/// 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");
@@ -280,11 +450,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()], false);
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 9f2772bc3..53e3b0969 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,69 +61,125 @@ 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 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);
return Ok(());
}
- render::print_resolution_table(&resolved);
- render::print_inventory(&inventory);
- render::print_plan(&removal_plan);
+ render::print_run_header();
- // 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);
+ // `-v` also unlocks the deep-sweep diagnostics printed via
+ // [`sweep::dbg_line`] during the stray search.
+ #[cfg(windows)]
+ sweep::set_verbose(parsed.verbose);
+
+ // The deep-sweep decision comes first: a broker-less, non-elevated sweep
+ // needs the user to opt into a UAC daemon start (or skip the sweep).
+ #[cfg(windows)]
+ let sweep = sweep_decision(&parsed)?;
+
+ // Overlap the slow work with the user's next decision: the drive-coverage
+ // reload + deep sweep start (quietly) in the background right away, while
+ // the elevation question is on screen. `-v` runs sequentially instead so
+ // its live diagnostics stay readable.
+ #[cfg(windows)]
+ let gather = start_stray_gather(&parsed, &removal_plan, sweep);
+
+ let gate = elevation_gate(&parsed, &mut removal_plan)?;
+ let skipped_elevation: Vec = match &gate {
+ ElevationChoice::ContinueWithout(items) => items.clone(),
+ ElevationChoice::NotNeeded | ElevationChoice::ElevateAtRemoval => Vec::new(),
+ };
+
+ // 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(&removal_plan, gather, sweep);
+ // The deep sweep may have STARTED the daemon (the no-broker UAC start) after
+ // the plan was snapshotted with none running — make sure the plan stops that
+ // live daemon before its binary is deleted, or its locked image would fail
+ // the runtime-binary delete with Access-denied.
+ #[cfg(windows)]
+ if let Some(pid) = running_daemon_pid() {
+ removal_plan.ensure_daemon_shutdown(pid);
+ }
+ #[cfg(windows)]
+ let stray_plan = &gathered.stray_plan;
+ #[cfg(not(windows))]
+ let no_strays = RemovalPlan::default();
+ #[cfg(not(windows))]
+ let stray_plan = &no_strays;
+
+ #[cfg(windows)]
+ render::print_coverage_notes(&gathered.coverage_notes);
+ render::print_inventory(&inventory);
+ render::print_resolution_table(&resolved);
+ #[cfg(windows)]
+ render::print_extra_table(&gathered.strays);
+ render::print_plan(&removal_plan, stray_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() {
+ render::print_dry_run_elevation_note();
+ }
print_dry_run_footer();
return Ok(());
}
- // M3 elevation gate (U-30): refuse before any effect when the plan needs
- // privilege the current process lacks. `uffs_mft::platform::is_elevated` is
- // cross-platform (Windows token check; Unix effective-uid 0), unlike the
- // Windows-only `uffs_winsvc::is_elevated`.
- if removal_plan.requires_elevation() && !uffs_mft::platform::is_elevated() {
- render::print_elevation_refusal(&removal_plan);
- bail!("uninstall needs Administrator for the items listed above; re-run elevated");
- }
-
// 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() {
return Ok(());
}
- // M4 consent (U-21): unless --yes, require explicit confirmation (default No)
- // before any destructive effect. 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();
+ // The broker service stays installed whenever the plan won't remove it (the
+ // non-elevated "continue without" choice dropped it), so its binary is
+ // locked and must be LEFT, not fought. A declined UAC on the `e` path is
+ // detected during execution.
+ let broker_remains = matches!(
+ inventory.broker_service,
+ inventory::BrokerServiceState::Installed
+ ) && !removal_plan
+ .items()
+ .any(|item| matches!(item.target, PlanTarget::RemoveService { .. }));
+ execute_all(
+ &removal_plan,
+ stray_plan,
+ remove_strays,
+ matches!(gate, ElevationChoice::ElevateAtRemoval),
+ broker_remains,
+ );
+ Ok(())
+}
+/// 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,
+ broker_remains: bool,
+) {
// M9: mark the run in progress (survives the lifecycle-dir deletion) so an
// interruption is detectable next launch. Best-effort: a failed marker write
// must not block the uninstall, but we surface it honestly.
@@ -124,44 +187,42 @@ 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(), elevate_via_uac);
+ 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, broker_remains));
+ }
+ if remove_strays {
+ // Strays are loose files, never the broker service's binary.
+ outcome.absorb(remove::execute(stray_plan, &mut effects, false));
+ }
+ if !outcome.is_empty() {
render::print_outcome(&outcome);
}
-
- // 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();
+ if let Err(err) = effects::schedule_self_delete(&self_paths) {
+ render::print_self_delete_warning(&err);
+ }
}
// M8 verify (U-81): confirm the targeted locations are gone, excluding the
// reboot-deferred self-binaries handled above.
- let to_check: Vec = plan_dirs(&removal_plan)
+ let to_check: Vec = plan_dirs(removal_plan)
.into_iter()
.filter(|dir| {
!self_paths
@@ -169,21 +230,176 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> {
.any(|self_path| self_path.starts_with(dir))
})
.collect();
- render::print_verification(&verify::still_present(&to_check));
+ render::print_verification(&verify::still_present(&to_check), outcome.is_clean());
// M9: clear the in-progress marker now the run finished.
if let Err(err) = journal::finish() {
render::print_journal_warning(&err);
}
- 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 mut removal_plan = plan::build_plan(&report, &inventory, parsed, &removable_path);
+ // Fold each binary's on-disk size into the plan (statting is IO the pure
+ // plan module leaves to us), so the "Reclaims ~N" line counts the binaries,
+ // not just the data dirs.
+ removal_plan.size_binaries(binary_dir_bytes);
+ (resolved, inventory, removal_plan)
+}
+
+/// Best-effort total on-disk size of the named binary stems inside `dir`
+/// (`uffsd` -> `uffsd.exe` on Windows). An absent / unreadable file contributes
+/// 0 — sizing must never fail the plan.
+fn binary_dir_bytes(dir: &std::path::Path, stems: &[String]) -> u64 {
+ stems
+ .iter()
+ .map(|stem| {
+ std::fs::metadata(dir.join(effects::exe_file_name(stem))).map_or(0, |meta| meta.len())
+ })
+ .fold(0, u64::saturating_add)
+}
+
+/// What the elevation gate decided for this run.
+enum ElevationChoice {
+ /// Elevated, `--dry-run`, or nothing needs Administrator — plan untouched.
+ 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 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` (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(
+ parsed: &UninstallArgs,
+ removal_plan: &mut RemovalPlan,
+) -> Result {
+ if parsed.dry_run || !removal_plan.requires_elevation() || uffs_mft::platform::is_elevated() {
+ return Ok(ElevationChoice::NotNeeded);
+ }
+ render::print_elevation_gate(removal_plan);
+ 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\
+ \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.
+#[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`)"
+ );
+ }
+ effects::remove_windows_service(service)
}
/// 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) {
@@ -212,39 +428,270 @@ fn plan_dirs(plan: &RemovalPlan) -> Vec {
.collect()
}
-/// Build the deep-sweep stray plan for the current platform.
-///
-/// 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 up-front deep-sweep decision. Windows-only.
+#[cfg(windows)]
+#[derive(Clone, Copy)]
+enum SweepDecision {
+ /// Run the sweep; `elevate_daemon` = start the index daemon with a UAC
+ /// prompt (the no-broker path the user opted into at the sweep gate).
+ Proceed {
+ /// Whether the daemon start requests elevation (`--elevate`).
+ elevate_daemon: bool,
+ },
+ /// Skip the sweep entirely (`--no-deep-sweep`, or the user/mode declined
+ /// the elevation a broker-less sweep would need).
+ Skip,
+}
+
+/// Decide up front whether (and how) the deep sweep runs. A complete sweep
+/// needs the index daemon covering every drive; without the Access Broker a
+/// daemon can only read the MFT **elevated**, so when coverage is incomplete,
+/// this run is not elevated, and no broker pipe is serving, the user chooses:
+/// start the daemon with a UAC prompt now, or skip the sweep. `--yes` and
+/// `--dry-run` never pop a surprise UAC — they skip with a note instead.
#[cfg(windows)]
-fn platform_stray_plan(parsed: &UninstallArgs, removal_plan: &RemovalPlan) -> RemovalPlan {
+#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
+fn sweep_decision(parsed: &UninstallArgs) -> Result {
+ /// Short broker-pipe probe (same budget as the daemon-management gate).
+ const BROKER_PROBE_MS: u32 = 600;
+
if parsed.no_deep_sweep {
- return RemovalPlan::default();
+ return Ok(SweepDecision::Skip);
}
- // 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);
+ if coverage::coverage_complete()
+ || uffs_mft::platform::is_elevated()
+ || uffs_winsvc::pipe_serving(uffs_broker_protocol::PIPE_NAME, BROKER_PROBE_MS)
+ {
+ return Ok(SweepDecision::Proceed {
+ elevate_daemon: false,
+ });
+ }
+ // A complete sweep would need an elevated daemon start.
+ if parsed.dry_run || parsed.assume_yes {
+ println!(
+ "\nDeep sweep skipped: without the Access Broker the index daemon needs\n\
+ Administrator to start. Run elevated (or install the broker) for a full sweep."
+ );
+ return Ok(SweepDecision::Skip);
+ }
+ println!(
+ "\nA thorough uninstall deep-sweeps every drive for stray UFFS files. Without\n\
+ the Access Broker, the index daemon can only start from an elevated process."
+ );
+ let choice = prompt_choice(
+ "\n d = deep sweep — start the daemon now (Windows shows one UAC prompt)\n\
+ \x20 s = skip the deep sweep (standard locations only)\n\
+ \n\
+ Choice [d/S]: ",
+ )?;
+ if matches!(choice.as_str(), "d" | "deep" | "deep sweep") {
+ Ok(SweepDecision::Proceed {
+ elevate_daemon: true,
+ })
+ } else {
+ println!("Deep sweep skipped; only the standard locations are cleaned.");
+ Ok(SweepDecision::Skip)
}
+}
+
+/// Everything the deep-sweep gather produces for the final presentation.
+/// Windows-only — off Windows the daemon indexes offline captures, not the
+/// live filesystem, so there is no stray phase at all.
+#[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 sweep decision is made, so the daemon index work overlaps
+/// the elevation question instead of costing wall-clock time after it. Quiet:
+/// all narration is deferred into the returned [`GatherOutcome`]. `None` when
+/// the sweep was skipped at the gate or is running sequentially (`-v`).
+///
+/// The known-dirs snapshot is taken before the elevation gate mutates the
+/// plan; that is safe because the gate only drops service/process items, which
+/// never contribute directories.
+#[cfg(windows)]
+fn start_stray_gather(
+ parsed: &UninstallArgs,
+ removal_plan: &RemovalPlan,
+ sweep: SweepDecision,
+) -> Option> {
+ let SweepDecision::Proceed { elevate_daemon } = sweep else {
+ return None;
+ };
+ if parsed.verbose {
+ return None;
+ }
+ GATHER_PHASE.store(0, core::sync::atomic::Ordering::Relaxed);
let known = plan_dirs(removal_plan);
+ Some(std::thread::spawn(move || {
+ gather_strays(&known, true, elevate_daemon)
+ }))
+}
+
+/// 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, elevate_daemon: bool) -> GatherOutcome {
+ let coverage_notes = coverage::ensure_drive_coverage(quiet, elevate_daemon);
+ GATHER_PHASE.store(1, core::sync::atomic::Ordering::Relaxed);
+
+ sweep::dbg_gap();
let mut search = sweep::DaemonSearch;
- let strays = sweep::version_strays(sweep::find_strays(&mut search, &known).unwrap_or_default());
- render::print_strays(&strays);
- plan::build_stray_plan(&strays)
+ 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()
+ ));
+
+ 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
+/// (the sweep was skipped at the gate). A panicked gather degrades to "no
+/// strays found".
+#[cfg(windows)]
+fn finish_stray_gather(
+ removal_plan: &RemovalPlan,
+ gather: Option>,
+ sweep: SweepDecision,
+) -> GatherOutcome {
+ if let Some(handle) = gather {
+ spinner_wait(&handle);
+ return handle.join().unwrap_or_default();
+ }
+ let SweepDecision::Proceed { elevate_daemon } = sweep else {
+ return GatherOutcome::default();
+ };
+ gather_strays(&plan_dirs(removal_plan), false, elevate_daemon)
+}
+
+/// The pid of the daemon that is running right now, or `None` if none answers.
+/// Used after the gather to fold a sweep-started daemon into the shutdown plan.
+#[cfg(windows)]
+fn running_daemon_pid() -> Option {
+ uffs_client::connect_sync::UffsClientSync::connect_raw()
+ .ok()
+ .and_then(|mut client| client.status().ok())
+ .map(|status| status.pid)
+ .filter(|&pid| pid != 0)
+}
+
+/// Animate a small spinner on the current line until `handle` finishes, with a
+/// label tracking the gather stage; the line is cleared before returning so
+/// the presentation starts clean.
+#[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] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
+ // One blank line between the sweep-decision prompt and the spinner, so the
+ // gather does not butt right up against "Choice [d/S]: d".
+ println!();
+ let mut frame = 0_usize;
+ while !handle.is_finished() {
+ let label = if GATHER_PHASE.load(core::sync::atomic::Ordering::Relaxed) == 0 {
+ "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\
+ \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`)
@@ -297,6 +744,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 4633358d0..90b0e4f40 100644
--- a/crates/uffs-cli/src/commands/uninstall/plan.rs
+++ b/crates/uffs-cli/src/commands/uninstall/plan.rs
@@ -19,11 +19,23 @@ 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";
+/// Heading of the shutdown group (daemon stop + broker service removal). Shared
+/// so `RemovalPlan::ensure_daemon_shutdown` (Windows-only) can find / recreate
+/// it verbatim.
+const SHUTDOWN_GROUP_TITLE: &str = "Shutdown (stopped last)";
+
+/// Heading of the data / cache / config group (the shutdown group must precede
+/// it — a running daemon holds handles inside these dirs).
+const DATA_GROUP_TITLE: &str = "Data / cache / config";
+
+/// Heading of the runtime-binaries group (deletable only after shutdown).
+const RUNTIME_GROUP_TITLE: &str = "Runtime binaries (after shutdown)";
+
/// The concrete target of a plan item: everything the executor needs, and
/// everything the renderer describes. Group ordering (in [`build_plan`]) plus
/// this discriminant define the safe removal order.
@@ -182,6 +194,89 @@ 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. 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| {
+ if item.needs_elevation {
+ dropped.push(item.target.describe());
+ return false;
+ }
+ true
+ });
+ }
+ self.groups.retain(|group| !group.items.is_empty());
+ dropped
+ }
+
+ /// Fill in the reclaim bytes of every binary-delete item, so the summary's
+ /// "Reclaims ~N" reflects the binaries too (not just the data dirs).
+ /// Statting files is IO, which this pure module leaves to the caller:
+ /// `size_of` maps a `(dir, stems)` binary-delete target to its on-disk
+ /// total (best-effort — an absent file contributes 0). `WinGet`
+ /// delegations and directory / process items are untouched (winget owns
+ /// its bytes; dir sizes already came from the inventory).
+ pub(crate) fn size_binaries(&mut self, size_of: impl Fn(&Path, &[String]) -> u64) {
+ for item in self.groups.iter_mut().flat_map(|group| &mut group.items) {
+ if let PlanTarget::DeleteBinaries { dir, stems } = &item.target {
+ item.bytes = size_of(dir, stems);
+ }
+ }
+ }
+
+ /// Make sure the plan stops the daemon at `pid` before its binary is
+ /// deleted. The deep sweep can *start* the daemon (the no-broker path's UAC
+ /// start) **after** the plan was snapshotted, so `report.running` had none
+ /// and the shutdown group carries no stop for it — without this the
+ /// freshly-started, possibly elevated daemon keeps its image locked and the
+ /// runtime-binary delete fails with Access-denied. No-op when a daemon stop
+ /// already exists. The executor stops it with a graceful shutdown RPC (no
+ /// caller elevation needed), so the elevation obtained to *start* it need
+ /// not be re-acquired to stop it.
+ #[cfg(windows)]
+ pub(crate) fn ensure_daemon_shutdown(&mut self, pid: u32) {
+ let already = self.items().any(|item| {
+ matches!(&item.target, PlanTarget::StopProcess { component, .. } if component == "daemon")
+ });
+ if already {
+ return;
+ }
+ let stop = PlanItem {
+ target: PlanTarget::StopProcess {
+ component: Component::Daemon.label().to_owned(),
+ pid,
+ },
+ needs_elevation: false,
+ scope: ItemScope::Any,
+ bytes: 0,
+ };
+ // Prepend to the existing shutdown group, or create it just before the
+ // data / runtime-binary groups it must precede (Windows locks the image
+ // of a running process, so the stop has to run first).
+ if let Some(group) = self
+ .groups
+ .iter_mut()
+ .find(|group| group.title == SHUTDOWN_GROUP_TITLE)
+ {
+ group.items.insert(0, stop);
+ return;
+ }
+ let at = self
+ .groups
+ .iter()
+ .position(|group| group.title == DATA_GROUP_TITLE || group.title == RUNTIME_GROUP_TITLE)
+ .unwrap_or(self.groups.len());
+ self.groups.insert(at, PlanGroup {
+ title: SHUTDOWN_GROUP_TITLE,
+ items: vec![stop],
+ });
+ }
+
/// Number of items across all groups.
pub(crate) fn item_count(&self) -> usize {
self.groups.iter().map(|group| group.items.len()).sum()
@@ -206,63 +301,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).
- 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()
- .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.
@@ -295,6 +351,69 @@ 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_GROUP_TITLE, shutdown, args.scope);
+
+ // 4. Data / cache / config dirs that exist (skip config under
+ // --keep-config). After the daemon shutdown: a running daemon holds open
+ // 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_GROUP_TITLE, 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_GROUP_TITLE, runtime, args.scope);
+
RemovalPlan { groups }
}
@@ -336,8 +455,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;
}
@@ -348,15 +491,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,
@@ -421,276 +580,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]
- #[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..e062336b8
--- /dev/null
+++ b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs
@@ -0,0 +1,524 @@
+// 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 size_binaries_fills_only_binary_delete_items_from_the_sizer() {
+ // A plan with a deletable binaries root plus a data dir (already sized) and
+ // a PATH item (never sized).
+ let report = DetectionReport {
+ roots: vec![root(Channel::Unmanaged, Scope::User, r"C:\Users\me\bin")],
+ running: Vec::new(),
+ };
+ let mut plan = built(
+ &report,
+ &inventory(BrokerServiceState::Absent, 1024),
+ &UninstallArgs::default(),
+ );
+ let dirs_before = plan
+ .items()
+ .filter(|item| matches!(item.target, PlanTarget::DeleteDir { .. }))
+ .map(|item| item.bytes)
+ .sum::();
+
+ // Sizer reports 4096 bytes for any binary target.
+ plan.size_binaries(|_dir, stems| 4096 * stems.len() as u64);
+
+ let binary_bytes = plan
+ .items()
+ .filter(|item| matches!(item.target, PlanTarget::DeleteBinaries { .. }))
+ .map(|item| item.bytes)
+ .sum::();
+ assert_eq!(binary_bytes, 4096, "the single `uffs` stem was sized");
+ // The data-dir bytes are untouched by size_binaries.
+ let dirs_after = plan
+ .items()
+ .filter(|item| matches!(item.target, PlanTarget::DeleteDir { .. }))
+ .map(|item| item.bytes)
+ .sum::();
+ assert_eq!(
+ dirs_after, dirs_before,
+ "dir sizes are left as the inventory set them"
+ );
+}
+
+#[cfg(windows)]
+#[test]
+fn ensure_daemon_shutdown_injects_a_stop_before_the_runtime_binaries() {
+ // No daemon was running when the plan was built (the deep sweep starts one
+ // afterwards), so the plan has no daemon stop — but it does have runtime
+ // binaries whose image the sweep-started daemon would lock.
+ let report = DetectionReport {
+ roots: vec![root(Channel::Unmanaged, Scope::User, r"C:\Users\me\bin")],
+ running: Vec::new(),
+ };
+ let mut plan = built(
+ &report,
+ &inventory(BrokerServiceState::Absent, 1024),
+ &UninstallArgs::default(),
+ );
+ assert!(
+ !has_target(&plan, |target| matches!(
+ target,
+ PlanTarget::StopProcess { .. }
+ )),
+ "no daemon stop before the injection"
+ );
+
+ plan.ensure_daemon_shutdown(9191);
+
+ let stop_group = plan
+ .groups
+ .iter()
+ .position(|group| {
+ group.items.iter().any(|item| {
+ matches!(&item.target, PlanTarget::StopProcess { component, pid }
+ if component == "daemon" && *pid == 9191)
+ })
+ })
+ .expect("the daemon stop was injected");
+ let runtime_group = plan
+ .groups
+ .iter()
+ .position(|group| group.title == "Runtime binaries (after shutdown)")
+ .expect("runtime-binaries group present");
+ assert!(
+ stop_group < runtime_group,
+ "the daemon stop must run before the runtime binaries are deleted"
+ );
+}
+
+#[cfg(windows)]
+#[test]
+fn ensure_daemon_shutdown_is_a_noop_when_a_stop_already_exists() {
+ let report = DetectionReport {
+ roots: Vec::new(),
+ running: vec![RunningProcess {
+ component: Component::Daemon,
+ pid: 4242,
+ image_path: None,
+ command_line: None,
+ version: None,
+ }],
+ };
+ let mut plan = built(
+ &report,
+ &inventory(BrokerServiceState::Absent, 1024),
+ &UninstallArgs::default(),
+ );
+ let before = plan.item_count();
+ plan.ensure_daemon_shutdown(9191);
+ assert_eq!(
+ plan.item_count(),
+ before,
+ "an existing daemon stop is not duplicated"
+ );
+ assert!(
+ plan.items().any(|item| matches!(&item.target,
+ PlanTarget::StopProcess { pid, .. } if *pid == 4242)),
+ "the analyzed daemon stop is kept (not replaced)"
+ );
+}
+
+#[test]
+fn drop_elevation_required_removes_broker_keeps_the_rest() {
+ let report = DetectionReport {
+ 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 cebc1a02d..b992ce328 100644
--- a/crates/uffs-cli/src/commands/uninstall/remove.rs
+++ b/crates/uffs-cli/src/commands/uninstall/remove.rs
@@ -16,9 +16,38 @@ use std::path::Path;
use anyhow::Result;
-use super::plan::{PlanTarget, RemovalPlan};
+use super::plan::{PlanItem, PlanTarget, RemovalPlan};
use crate::commands::update::model::Scope;
+/// Marker error: the elevation an item needed was declined at the UAC prompt.
+/// The executor recognises it (via downcast) and LEAVES the Access Broker —
+/// service plus its still-locked binary — as a clean "left" outcome, instead of
+/// attempting-and-failing each with a raw Access-denied.
+#[derive(Debug)]
+pub(crate) struct ElevationDeclined;
+
+impl core::fmt::Display for ElevationDeclined {
+ fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+ f.write_str("elevation was declined at the UAC prompt")
+ }
+}
+
+impl core::error::Error for ElevationDeclined {}
+
+/// Reason recorded when the broker service is left because elevation was
+/// declined at the UAC prompt.
+const BROKER_SERVICE_LEFT: &str = "the Access Broker (a LocalSystem service) needs Administrator";
+
+/// Reason recorded when the broker binary is left: its service is still
+/// running, so the image is locked and cannot be deleted without stopping the
+/// service.
+const BROKER_BINARY_LEFT: &str = "the Access Broker service is still running";
+
+/// Whether `stem` names the broker binary (locked while its service runs).
+const fn is_broker_stem(stem: &str) -> bool {
+ stem.eq_ignore_ascii_case("uffs-broker")
+}
+
/// The side effects the executor performs, injected so the walk is testable.
pub(crate) trait Effects {
/// Stop a running UFFS process by component label + pid.
@@ -47,6 +76,10 @@ pub(crate) enum ItemStatus {
Done,
/// The item failed; carries the error text.
Failed(String),
+ /// The item was deliberately left in place — not a failure to fix, but a
+ /// consequence of a choice (elevation declined at the UAC prompt, so the
+ /// broker and its locked binary stay). Carries the plain-language reason.
+ Skipped(String),
}
/// The result of executing a whole plan: one entry per item, in order.
@@ -62,6 +95,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
@@ -78,25 +123,114 @@ impl RemovalOutcome {
.count()
}
- /// Whether every item completed.
- pub(crate) fn all_done(&self) -> bool {
- self.failed_count() == 0
+ /// Number of items deliberately left in place (e.g. the broker after a
+ /// declined elevation).
+ pub(crate) fn skipped_count(&self) -> usize {
+ self.results
+ .iter()
+ .filter(|(_, status)| matches!(status, ItemStatus::Skipped(_)))
+ .count()
+ }
+
+ /// Whether the run removed everything it set out to — nothing failed and
+ /// nothing was left behind. Gates the "all gone" verification claim.
+ pub(crate) fn is_clean(&self) -> bool {
+ self.failed_count() == 0 && self.skipped_count() == 0
}
}
/// Execute `plan` in order against `effects`, recording each item's outcome.
/// Best-effort: a failing item is recorded and the walk continues.
-pub(crate) fn execute(plan: &RemovalPlan, effects: &mut dyn Effects) -> RemovalOutcome {
+///
+/// `broker_remains` is `true` when the Access Broker service will still be
+/// installed after this run — the non-elevated "continue without" choice drops
+/// the service item up front — so its binary is locked from the start and is
+/// *left* rather than fought. It also flips `true` if an in-plan service
+/// removal is declined at the UAC prompt. Either way the broker's binary is
+/// recorded as [`ItemStatus::Skipped`], never a raw Access-denied failure.
+pub(crate) fn execute(
+ plan: &RemovalPlan,
+ effects: &mut dyn Effects,
+ broker_remains: bool,
+) -> RemovalOutcome {
let mut outcome = RemovalOutcome::default();
+ let mut remains = broker_remains;
for item in plan.items() {
- let description = item.target.describe();
- let status = match dispatch(&item.target, effects) {
+ run_item(item, effects, &mut remains, &mut outcome);
+ }
+ outcome
+}
+
+/// Execute one plan item, folding its result into `outcome`. Sets
+/// `broker_remains` when an in-plan broker service removal is declined at the
+/// UAC prompt, so the later broker binary is left rather than fought.
+fn run_item(
+ item: &PlanItem,
+ effects: &mut dyn Effects,
+ broker_remains: &mut bool,
+ outcome: &mut RemovalOutcome,
+) {
+ let description = item.target.describe();
+ if let PlanTarget::RemoveService { service } = &item.target {
+ match effects.remove_service(service) {
+ Ok(()) => outcome.record(description, ItemStatus::Done),
+ Err(err) if err.downcast_ref::().is_some() => {
+ *broker_remains = true;
+ outcome.record(
+ description,
+ ItemStatus::Skipped(BROKER_SERVICE_LEFT.to_owned()),
+ );
+ }
+ Err(err) => outcome.record(description, ItemStatus::Failed(format!("{err:#}"))),
+ }
+ return;
+ }
+ // The broker service is staying (declined, or the non-elevated run left it),
+ // so it still runs and locks uffs-broker.exe: delete the other runtime
+ // binaries, leave the broker's alongside its service.
+ if let PlanTarget::DeleteBinaries { dir, stems } = &item.target
+ && *broker_remains
+ && stems.iter().any(|stem| is_broker_stem(stem))
+ {
+ delete_binaries_leaving_broker(dir, stems, effects, outcome);
+ return;
+ }
+ let status = match dispatch(&item.target, effects) {
+ Ok(()) => ItemStatus::Done,
+ Err(err) => ItemStatus::Failed(format!("{err:#}")),
+ };
+ outcome.record(description, status);
+}
+
+/// Delete every runtime binary in `dir` EXCEPT the broker's (whose service is
+/// still running): the deletable ones are removed as one item, the broker
+/// binary is recorded as left — a clean outcome, not an Access-denied failure.
+fn delete_binaries_leaving_broker(
+ dir: &Path,
+ stems: &[String],
+ effects: &mut dyn Effects,
+ outcome: &mut RemovalOutcome,
+) {
+ let (broker, rest): (Vec, Vec) =
+ stems.iter().cloned().partition(|stem| is_broker_stem(stem));
+ if !rest.is_empty() {
+ let description = format!("{} binaries in {}", rest.len(), dir.display());
+ let status = match effects.delete_binaries(dir, &rest) {
Ok(()) => ItemStatus::Done,
Err(err) => ItemStatus::Failed(format!("{err:#}")),
};
outcome.record(description, status);
}
- outcome
+ for stem in broker {
+ outcome.record(
+ format!(
+ "{} in {}",
+ super::effects::exe_file_name(&stem),
+ dir.display()
+ ),
+ ItemStatus::Skipped(BROKER_BINARY_LEFT.to_owned()),
+ );
+ }
}
/// Route one target to the matching [`Effects`] call.
@@ -138,6 +272,9 @@ mod tests {
struct RecordingEffects {
calls: Vec,
fail_marker: Option,
+ /// When set, `remove_service` returns [`super::ElevationDeclined`], as
+ /// a declined UAC prompt does.
+ decline_service: bool,
}
impl Effects for RecordingEffects {
@@ -147,6 +284,9 @@ mod tests {
}
fn remove_service(&mut self, service: &str) -> Result<()> {
self.calls.push(format!("remove_service:{service}"));
+ if self.decline_service {
+ return Err(super::ElevationDeclined.into());
+ }
Ok(())
}
fn delete_binaries(&mut self, dir: &Path, stems: &[String]) -> Result<()> {
@@ -214,17 +354,140 @@ mod tests {
fn executes_every_item_in_group_order() {
let plan = full_plan();
let mut effects = RecordingEffects::default();
- let outcome = execute(&plan, &mut effects);
- // Processes (stop) precede Binaries (delete), which precede Data dirs.
+ let outcome = execute(&plan, &mut effects, false);
+ // Teardown-last ordering: tool binaries first (the tooling stays
+ // usable during the run), then the daemon shutdown, then the data
+ // dirs it had open handles in.
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());
+ assert!(outcome.is_clean());
assert_eq!(outcome.done_count(), 3);
}
+ /// A plan with the broker service installed + a root holding the broker
+ /// binary alongside another runtime binary, so the decline path has both a
+ /// service to leave and a broker image to leave.
+ fn broker_plan() -> crate::commands::uninstall::plan::RemovalPlan {
+ let report = DetectionReport {
+ roots: vec![InstallRoot {
+ dir: PathBuf::from("/opt/uffs"),
+ channel: Channel::Unmanaged,
+ scope: Scope::User,
+ anchored_by: Vec::new(),
+ binaries: ["uffsd", "uffs-broker"]
+ .into_iter()
+ .map(|name| BinaryInfo {
+ name: name.to_owned(),
+ version: None,
+ })
+ .collect(),
+ }],
+ running: Vec::new(),
+ };
+ let inventory = Inventory {
+ dirs: Vec::new(),
+ broker_service: BrokerServiceState::Installed,
+ };
+ build_plan(&report, &inventory, &UninstallArgs::default(), &[])
+ }
+
+ #[test]
+ fn declined_elevation_leaves_the_broker_service_and_binary_not_fails_them() {
+ let plan = broker_plan();
+ let mut effects = RecordingEffects {
+ decline_service: true,
+ ..RecordingEffects::default()
+ };
+ // Broker is in the plan (an `e` run); the declined UAC flips the flag.
+ let outcome = execute(&plan, &mut effects, false);
+
+ // The broker binary is never even attempted (its service still runs);
+ // only the service removal + the OTHER runtime binary were called.
+ assert!(
+ !effects
+ .calls
+ .iter()
+ .any(|call| call.contains("uffs-broker")),
+ "the broker binary delete must not be attempted: {:?}",
+ effects.calls
+ );
+ // Two items LEFT (the service + the broker binary), zero hard failures.
+ assert_eq!(outcome.skipped_count(), 2, "service + broker binary left");
+ assert_eq!(outcome.failed_count(), 0, "nothing is a hard failure");
+ assert!(!outcome.is_clean(), "leftovers mean the run is not clean");
+ // The deletable runtime binary (uffsd) still went through as one item.
+ assert!(
+ effects
+ .calls
+ .iter()
+ .any(|call| call == "delete_binaries:/opt/uffs:1"),
+ "the non-broker runtime binary is still removed: {:?}",
+ effects.calls
+ );
+ }
+
+ #[test]
+ fn broker_remains_leaves_the_broker_binary_up_front() {
+ // The `c` path leaves the broker: the gate dropped the service item, so
+ // the plan has NO RemoveService (modelled here with the service absent)
+ // and `broker_remains` is true from the start. The broker binary is then
+ // left without any remove_service call — no Access-denied.
+ let report = DetectionReport {
+ roots: vec![InstallRoot {
+ dir: PathBuf::from("/opt/uffs"),
+ channel: Channel::Unmanaged,
+ scope: Scope::User,
+ anchored_by: Vec::new(),
+ binaries: ["uffsd", "uffs-broker"]
+ .into_iter()
+ .map(|name| BinaryInfo {
+ name: name.to_owned(),
+ version: None,
+ })
+ .collect(),
+ }],
+ running: Vec::new(),
+ };
+ let inventory = Inventory {
+ dirs: Vec::new(),
+ broker_service: BrokerServiceState::Absent,
+ };
+ let plan = build_plan(&report, &inventory, &UninstallArgs::default(), &[]);
+ let mut effects = RecordingEffects::default();
+ let outcome = execute(&plan, &mut effects, true);
+
+ assert!(
+ !effects
+ .calls
+ .iter()
+ .any(|call| call.contains("remove_service")),
+ "no service removal is attempted: {:?}",
+ effects.calls
+ );
+ assert!(
+ !effects
+ .calls
+ .iter()
+ .any(|call| call.contains("uffs-broker")),
+ "the locked broker binary is not attempted: {:?}",
+ effects.calls
+ );
+ assert_eq!(outcome.skipped_count(), 1, "just the broker binary is left");
+ assert_eq!(outcome.failed_count(), 0, "no Access-denied failure");
+ // uffsd still deleted (the non-broker runtime binary).
+ assert!(
+ effects
+ .calls
+ .iter()
+ .any(|call| call == "delete_binaries:/opt/uffs:1"),
+ "the non-broker runtime binary is still removed: {:?}",
+ effects.calls
+ );
+ }
+
#[test]
fn a_failing_item_is_recorded_and_the_rest_continue() {
let plan = full_plan();
@@ -232,12 +495,12 @@ mod tests {
fail_marker: Some("/x/cache".to_owned()),
..RecordingEffects::default()
};
- let outcome = execute(&plan, &mut effects);
+ let outcome = execute(&plan, &mut effects, false);
// All three were attempted; the cache dir failed, the other two done.
assert_eq!(effects.calls.len(), 3);
assert_eq!(outcome.failed_count(), 1);
assert_eq!(outcome.done_count(), 2);
- assert!(!outcome.all_done());
+ assert!(!outcome.is_clean());
let failed = outcome
.results
.iter()
diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs
index 8a3e9d84a..fc6489fc2 100644
--- a/crates/uffs-cli/src/commands/uninstall/render.rs
+++ b/crates/uffs-cli/src/commands/uninstall/render.rs
@@ -5,40 +5,130 @@
//! resolution table + the artifact inventory, in human form and as `--json`.
//! The removal plan is layered on in later milestones.
+use std::path::{Path, PathBuf};
+
use serde_json::{Value, json};
use super::inventory::Inventory;
-use super::plan::RemovalPlan;
+use super::plan::{PlanTarget, RemovalPlan};
use super::remove::{ItemStatus, RemovalOutcome};
use super::resolve_order::{ResolutionState, StemResolution};
#[cfg(windows)]
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
+/// 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")
+ );
+}
+
+/// 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: for each stem, every copy in
-/// OS search order, with the one a bare command runs flagged ACTIVE.
+/// 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("-");
- 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!(
+ "\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.
+ let print_row = |binary: &str, version: &str, status: &str, source: &str, location: &str| {
+ println!(
+ " {binary: = Vec::new();
+ for item in plan.items() {
+ // Coalesce all binary deletes for one directory into a single
+ // "N binaries in " line. The internal tools-vs-runtime split (and
+ // its group headings) is a teardown-ordering detail — the user just
+ // wants to know how many binaries in which folder go away.
+ if let PlanTarget::DeleteBinaries { dir, .. } = &item.target {
+ if shown_binary_dirs.iter().any(|shown| shown == dir) {
+ continue;
+ }
+ shown_binary_dirs.push(dir.clone());
+ let (count, needs_admin) = binary_dir_totals(plan, dir);
println!(
- " [{index}] {desc}{elevated}",
- desc = item.target.describe()
+ " [{index}] {count} binaries in {}{}",
+ dir.display(),
+ admin_flag(needs_admin),
);
index = index.saturating_add(1);
+ continue;
}
+ println!(
+ " [{index}] {desc}{elevated}",
+ desc = item.target.describe(),
+ elevated = admin_flag(item.needs_elevation),
+ );
+ index = index.saturating_add(1);
+ }
+ // The EXTRA files ride the same summary so nothing is hidden from the
+ // final picture, but they are a separate choice: the ALL/CORE question.
+ if !extra.is_empty() {
+ println!(
+ " [{index}] {count} file(s) found elsewhere (removed only with ALL)",
+ count = extra.item_count(),
+ );
+ }
+ if extra.is_empty() {
+ println!("\nReclaims ~{}.", human_bytes(plan.total_bytes()));
+ } else {
+ println!(
+ "\nReclaims ~{}, plus {} file(s) removed only with ALL.",
+ human_bytes(plan.total_bytes()),
+ extra.item_count(),
+ );
}
- println!(
- "\nReclaims ~{} across {} item(s).",
- human_bytes(plan.total_bytes()),
- plan.item_count(),
- );
}
-/// Print the elevation refusal (U-30): the items that need Administrator and
-/// the re-run hint. Goes to stderr; the caller exits non-zero without any
-/// effect.
-#[expect(clippy::print_stderr, reason = "CLI user-facing error")]
-pub(crate) fn print_elevation_refusal(plan: &RemovalPlan) {
- eprintln!("\nThis uninstall includes items that require Administrator:");
+/// The ` (needs Administrator)` suffix, or empty when the item is removable
+/// as the current user.
+const fn admin_flag(needs_elevation: bool) -> &'static str {
+ if needs_elevation {
+ " (needs Administrator)"
+ } else {
+ ""
+ }
+}
+
+/// Sum the binary stems across every `DeleteBinaries` item targeting `dir` (the
+/// tools and runtime passes land in separate groups), and whether any of them
+/// needs Administrator. Used to fold the split into one consent line.
+fn binary_dir_totals(plan: &RemovalPlan, dir: &Path) -> (usize, bool) {
+ let mut count: usize = 0;
+ let mut needs_admin = false;
+ for item in plan.items() {
+ if let PlanTarget::DeleteBinaries {
+ dir: item_dir,
+ stems,
+ } = &item.target
+ && item_dir == dir
+ {
+ count = count.saturating_add(stems.len());
+ needs_admin |= item.needs_elevation;
+ }
+ }
+ (count, needs_admin)
+}
+
+/// The up-front elevation gate (U-30): the FIRST thing a non-elevated run says.
+/// Explains which items need an Administrator terminal and why, before any
+/// analysis output — the question that follows is the only elevation decision.
+#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
+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):\n"
+ );
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"
+}
+
+/// 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.");
+}
+
+/// 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."
+ );
+}
+
+/// 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_dry_run_elevation_note() {
+ println!(
+ "\nNote: items marked (needs Administrator) require an elevated terminal; a\n\
+ non-elevated run asks up front whether to continue without them."
);
}
-/// 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):"
+ "\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("-");
- println!(" {version:<9} {}", stray.path.display());
+ let print_row = |binary: &str, version: &str, location: &str| {
+ println!(" {binary: 0 {
+ parts.push(format!("{failed} failed"));
+ }
+ if skipped > 0 {
+ parts.push(format!("{skipped} left"));
+ }
+ println!("\nRemoval finished: {}.", parts.join(", "));
+
for (description, status) in &outcome.results {
- if let ItemStatus::Failed(error) = status {
- println!(" FAILED {description} ({error})");
+ match status {
+ ItemStatus::Failed(error) => println!(" FAILED {description} ({error})"),
+ ItemStatus::Skipped(reason) => println!(" LEFT {description} ({reason})"),
+ ItemStatus::Done => {}
}
}
- if !outcome.all_done() {
+
+ // Left items are always the broker after a declined elevation (Windows-only):
+ // one clear next step, not the generic file-in-use hint.
+ if skipped > 0 {
+ println!(
+ "\nThe Access Broker was left because elevation was declined. Re-run\n\
+ `uffs --uninstall` from an Administrator terminal to remove it."
+ );
+ }
+ if failed > 0 {
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."
);
}
}
diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs
index 7282bca79..120145866 100644
--- a/crates/uffs-cli/src/commands/uninstall/sweep.rs
+++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs
@@ -11,24 +11,55 @@
//! 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;
-use serde_json::Value;
-
-/// Family-file name patterns the sweep searches for.
-const STRAY_PATTERNS: &[&str] = &[
- "uffs.exe",
- "uffsd.exe",
- "uffsmcp.exe",
- "uffs-broker.exe",
- "uffs-update.exe",
- "uffs-mft.exe",
- "uffs-tui*.exe",
- "uffs-gui*.exe",
- "*_compact.uffs",
- "*_usn.cursor",
-];
+
+/// 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) {
+ if SWEEP_VERBOSE.load(core::sync::atomic::Ordering::Relaxed) {
+ println!(" [sweep] {msg}");
+ }
+}
+
+/// 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"];
+
+/// 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 {
@@ -46,20 +77,180 @@ 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()
- .map(|path| {
- let version = is_probeable_binary(&path)
- .then(|| crate::commands::update::binaries::probe_version(&path))
- .flatten();
- StrayHit { path, version }
- })
- .collect()
+/// 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));
+ }
+ 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.
+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
@@ -75,18 +266,58 @@ 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)? {
- if !is_under_any(&hit, known_dirs) {
+ 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 {
+ 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();
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_stems().any(|family| family.eq_ignore_ascii_case(stem))
+}
+
/// 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 {
@@ -108,54 +339,119 @@ impl Search for DaemonSearch {
let Ok(mut client) = uffs_client::connect_sync::UffsClientSync::connect_raw() else {
return Ok(Vec::new());
};
- let args = vec![
+ // `--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).
+ //
+ // `--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(),
- "1000".to_owned(),
+ "5000".to_owned(),
];
- let Ok(value) = client.search_cli_raw(&args) else {
+ // 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());
};
- 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 std::path::{Path, PathBuf};
use anyhow::Result;
- use super::{Search, extract_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.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.
+ 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);
@@ -195,7 +491,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"),
@@ -208,11 +504,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);
}
}
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() {
diff --git a/crates/uffs-client/src/connect_sync.rs b/crates/uffs-client/src/connect_sync.rs
index 956233a64..b88d1c93a 100644
--- a/crates/uffs-client/src/connect_sync.rs
+++ b/crates/uffs-client/src/connect_sync.rs
@@ -14,6 +14,7 @@
//! | macOS/Linux | `std::os::unix::net::UnixStream` |
//! | Windows | Named pipe via `std::fs::OpenOptions` (no Winsock) |
+use core::sync::atomic::{AtomicBool, Ordering};
use std::io::{BufRead as _, BufReader, Read, Write};
use crate::connect_sync_autostart::auto_start_daemon;
@@ -22,6 +23,26 @@ use crate::daemon_spawn::{ElevationPolicy, resolve_elevation_policy};
use crate::error::ClientError;
use crate::protocol::response::DaemonStatus;
+/// When set, the auto-start connect loop suppresses its user-facing
+/// `[uffs] connect attempt …` retry chatter (default off). A caller driving a
+/// daemon (re)start *behind its own progress UI* — the uninstall deep-sweep
+/// coverage reload, which runs under a spinner on another thread — sets this so
+/// the retry lines do not garble that display. Process-wide, best set via a
+/// scoped guard on the caller's side so it never sticks.
+static QUIET_AUTOSTART: AtomicBool = AtomicBool::new(false);
+
+/// Suppress (or restore) the auto-start connect retry chatter (the
+/// `[uffs] connect attempt …` lines). The caller owns balancing this back to
+/// `false` — best via a scoped guard so it never sticks.
+pub fn set_quiet_autostart(quiet: bool) {
+ QUIET_AUTOSTART.store(quiet, Ordering::Relaxed);
+}
+
+/// Whether the auto-start retry chatter is currently suppressed.
+fn quiet_autostart() -> bool {
+ QUIET_AUTOSTART.load(Ordering::Relaxed)
+}
+
/// Synchronous thin client for the UFFS daemon.
///
/// One request, one response, no event loop. Phase 3b decisions:
@@ -304,7 +325,7 @@ impl UffsClientSync {
// Log sparingly — eprintln is intentional user-facing output
// during daemon auto-start retries (no tracing in thin client).
- if attempt <= 3 || attempt == max_attempts {
+ if !quiet_autostart() && (attempt <= 3 || attempt == max_attempts) {
#[expect(
clippy::print_stderr,
reason = "intentional user-facing retry progress"
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)]
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-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;
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/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");
+}
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() {
diff --git a/deny.toml b/deny.toml
index 0295d9287..65622b475 100644
--- a/deny.toml
+++ b/deny.toml
@@ -20,6 +20,22 @@ ignore = [
# No action required from our side - Polars team will handle migration
# See: https://rustsec.org/advisories/RUSTSEC-2025-0141
"RUSTSEC-2025-0141",
+ # quick-xml <0.41: unbounded namespace-declaration allocation in NsReader (DoS on
+ # untrusted XML). Transitive-only: polars-io -> object_store 0.13 -> quick-xml ^0.39.
+ # NOT reachable in UFFS: the vulnerable path is object_store's cloud-store XML LIST
+ # parsing; UFFS only reads the local NTFS MFT and local index files, never a cloud
+ # object path. No upstream fix exists to bump to as of 2026-07-02 — the newest
+ # object_store (0.14.0) still requires quick-xml ^0.40.1 (< the fixed 0.41), and we
+ # are already on the latest polars (0.54.4). Remove this ignore when object_store
+ # ships a quick-xml >=0.41 release AND polars adopts it.
+ # See: https://rustsec.org/advisories/RUSTSEC-2026-0195
+ "RUSTSEC-2026-0195",
+ # Same crate, same path, same reasoning as RUSTSEC-2026-0195 above: quick-xml <0.41
+ # quadratic run time checking a start tag for duplicate attribute names (DoS on
+ # untrusted XML). Unreachable in UFFS (no cloud object paths); no upstream fix
+ # reachable yet. Remove together with the ignore above.
+ # See: https://rustsec.org/advisories/RUSTSEC-2026-0194
+ "RUSTSEC-2026-0194",
]
[licenses]
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/gates.toml b/scripts/ci/gates.toml
index 9f21378f1..152aa6da4 100644
--- a/scripts/ci/gates.toml
+++ b/scripts/ci/gates.toml
@@ -440,6 +440,22 @@ only links reachable from the public API surface, so a broken
sibling) silently renders as dead text instead of failing. Cross-
platform — `#[cfg(windows)]` items absent on the macOS/Linux runner
are written as code spans, not links.
+
+INTENTIONAL: we do NOT add `-D rustdoc::private_intra_doc_links`, and
+this is the desired posture — do not "harden" it in. `-Dwarnings`
+already denies the whole default warning set; that lint stays quiet
+here because `--document-private-items` documents the internals, so a
+`//!`/public link to a `pub(crate)` sibling is a *valid internal
+cross-reference*, not a leak. (Verified: `-Dwarnings` +
+`--document-private-items` = 0 warnings; adding an explicit
+`-D rustdoc::private_intra_doc_links` flips 2 such links in
+`uffs-cli/src/main.rs` to errors.) That lint guards a *published*
+crate's public docs from dangling to items a downstream user cannot
+see — a concern that does not apply to our internal-only doc build.
+Turning it on would force valid internal `[symbol]` links down to
+dead code spans (a doc regression) for zero correctness gain. The
+real failure class we care about — broken / unresolved links anywhere
+— is already caught by `broken-intra-doc-links` under `-Dwarnings`.
"""
[[gate]]
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");
+}