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"); +}