Skip to content
4 changes: 4 additions & 0 deletions crates/uffs-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ pub(crate) mod daemon_status;
/// under the 800-LOC policy ceiling. Forward-looking: 8-D `forget`
/// and 8-E `status_drives` will land their shims here as well.
pub(crate) mod daemon_tiering;
/// Shared elevation gate for the mutating flows (uninstall / update): surface
/// admin-only work up front and decide once (elevate / continue-without /
/// abort) instead of failing mid-flow. Keeps both flows' elevation UX aligned.
pub(crate) mod elevation;
// Index and info subcommands were merged into other modules.
/// MCP server management subcommands.
pub(crate) mod mcp_mgmt;
Expand Down
122 changes: 89 additions & 33 deletions crates/uffs-cli/src/commands/daemon_mgmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,7 @@ pub(crate) fn daemon(action: &DaemonAction) -> Result<()> {
),
DaemonAction::Status { verbose, json } => daemon_status::daemon_status(*verbose, *json),
DaemonAction::Stop => daemon_stop(),
DaemonAction::Kill => {
daemon_kill();
Ok(())
}
DaemonAction::Kill => daemon_kill(),
DaemonAction::Restart => daemon_restart(),
DaemonAction::Load {
mft_file,
Expand Down Expand Up @@ -183,7 +180,7 @@ pub(crate) fn daemon(action: &DaemonAction) -> Result<()> {
/// (Windows uses a broker-aware variant — see the `#[cfg(windows)]` impl
/// below.)
#[cfg(unix)]
fn mutating_management_needs_elevation() -> bool {
pub(crate) fn mutating_management_needs_elevation() -> bool {
daemon_owner_needs_elevation(&pid_file_path(), uffs_mft::current_euid())
}

Expand Down Expand Up @@ -214,7 +211,7 @@ fn daemon_owner_needs_elevation(pid_file: &std::path::Path, caller_euid: u32) ->
///
/// Otherwise (an elevated daemon, no broker) managing it needs Administrator.
#[cfg(windows)]
fn mutating_management_needs_elevation() -> bool {
pub(crate) fn mutating_management_needs_elevation() -> bool {
/// Short pipe probe — this gate runs once per management command.
const BROKER_GATE_PROBE_MS: u32 = 600;

Expand Down Expand Up @@ -247,7 +244,7 @@ fn launch_state_says_non_elevated(pid_path: &std::path::Path) -> bool {
/// Other non-Unix targets (WASM, bare-metal — not real deployments): keep the
/// conservative default of always requiring elevation.
#[cfg(not(any(unix, windows)))]
const fn mutating_management_needs_elevation() -> bool {
pub(crate) const fn mutating_management_needs_elevation() -> bool {
true
}

Expand Down Expand Up @@ -413,8 +410,15 @@ fn daemon_stop() -> Result<()> {
}

/// `uffs --daemon kill` — hard kill via PID file or socket discovery + cleanup.
///
/// The PID file / socket are removed **only after** the process is confirmed
/// gone. If the terminate did not take effect — almost always because the
/// daemon was started elevated and this is a non-elevated shell — the PID file
/// is left in place (so the daemon stays discoverable) and an error explains
/// how to finish. This closes the old "PID file deleted, process still
/// running" half-kill.
#[expect(clippy::print_stdout, reason = "CLI user-facing output")]
fn daemon_kill() {
fn daemon_kill() -> Result<()> {
let pid_path = pid_file_path();

let mut pid =
Expand All @@ -428,41 +432,93 @@ fn daemon_kill() {
pid = Some(status.pid);
}

if let Some(target_pid) = pid {
let Some(target_pid) = pid else {
// Nothing running → sweep any stale files (no-op if already absent).
drop(std::fs::remove_file(&pid_path));
drop(std::fs::remove_file(socket_path()));
if !is_quiet() {
println!("No daemon found (no PID file, no socket connection).");
}
return Ok(());
};

// A PID file that outlived its process (crash / reboot / recycled PID): the
// daemon is already gone, so there is nothing to kill — just clean up. This
// avoids reporting a "kill" when nothing was running.
if !uffs_client::daemon_ctl::is_pid_alive(target_pid) {
drop(std::fs::remove_file(&pid_path));
drop(std::fs::remove_file(socket_path()));
if !is_quiet() {
println!("Killing daemon (PID {target_pid})...");
println!("No running daemon (stale PID {target_pid}); cleaned up PID file + socket.");
}
kill_pid(target_pid);
} else if !is_quiet() {
println!("No daemon found (no PID file, no socket connection).");
return Ok(());
}

// Always clean up stale files.
drop(std::fs::remove_file(&pid_path));
drop(std::fs::remove_file(socket_path()));
if pid.is_some() && !is_quiet() {
println!("Daemon killed. PID file and socket cleaned up.");
if !is_quiet() {
println!("Killing daemon (PID {target_pid})...");
}

if kill_pid(target_pid) {
// Confirmed gone — safe to drop the discovery files.
drop(std::fs::remove_file(&pid_path));
drop(std::fs::remove_file(socket_path()));
if !is_quiet() {
println!("Daemon killed. PID file and socket cleaned up.");
}
return Ok(());
}

// Still alive → do NOT remove the PID file (no half-kill; keep it
// discoverable). Almost always: an elevated daemon vs a non-elevated shell.
#[cfg(windows)]
anyhow::bail!(
"Could not terminate the daemon (PID {target_pid}) — it is still running.\n\n\
It was most likely started from an elevated (Administrator) terminal, so a\n\
non-elevated process cannot stop it. The PID file was left in place so the\n\
daemon stays discoverable.\n\n\
Re-run from an elevated terminal:\n\
\x20 uffs --daemon kill"
);
#[cfg(not(windows))]
anyhow::bail!(
"Could not terminate the daemon (PID {target_pid}) — it is still running.\n\n\
It was most likely started as root, so a non-root process cannot stop it.\n\
The PID file was left in place so the daemon stays discoverable.\n\n\
Re-run with elevated privileges:\n\
\x20 sudo uffs --daemon kill"
);
}

/// Send SIGKILL (Unix) or taskkill (Windows) to a process.
fn kill_pid(pid: u32) {
/// Terminate `pid` (SIGKILL on Unix, `taskkill /F` on Windows), then
/// **confirm** it is actually gone. Returns `true` only when the process is no
/// longer alive.
///
/// The OS call's exit code is unreliable for our case — a non-elevated
/// `taskkill` of an elevated daemon is denied — so we verify via
/// [`uffs_client::daemon_ctl::is_pid_alive`], polling briefly (a `/F` kill is
/// near-instant but not synchronous).
fn kill_pid(pid: u32) -> bool {
#[cfg(unix)]
{
drop(
std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output(),
);
}
drop(
std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output(),
);
#[cfg(windows)]
{
drop(
std::process::Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.output(),
);
drop(
std::process::Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.output(),
);

// Poll for the process to disappear (fast path returns on the first check).
for _ in 0..10_u8 {
if !uffs_client::daemon_ctl::is_pid_alive(pid) {
return true;
}
std::thread::sleep(core::time::Duration::from_millis(100));
}
false
}

/// `uffs --daemon restart` — stop, capture data sources, then re-launch.
Expand Down
105 changes: 105 additions & 0 deletions crates/uffs-cli/src/commands/elevation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2025-2026 SKY, LLC.

//! Shared elevation gate for the mutating flows.
//!
//! A non-elevated run that has admin-only work is told **up front** what needs
//! Administrator and decides once — continue without the admin items (they are
//! dropped so the summary never lists work that will not happen), or abort and
//! re-run from an elevated terminal. Surfacing this before any mutation is what
//! keeps a flow from failing halfway through (the uninstall flow pioneered it;
//! `uffs --update` reuses this gate so both behave alike).
//!
//! Unlike the uninstall flow — which has a one-shot elevated helper it can
//! spawn via a single UAC prompt at removal time — the update flow has no
//! in-flow elevation (and `winget` itself *refuses* to upgrade a user package
//! from an elevated shell), so the elevated path here is simply "re-run
//! elevated". The choice is therefore binary on every platform.

use anyhow::{Context as _, Result, bail};

/// A plan the elevation gate can reason about: some of its work needs
/// Administrator, and those items can be dropped to "continue without".
pub(crate) trait ElevatablePlan {
/// Whether any item in the plan needs elevation.
fn requires_elevation(&self) -> bool;

/// Drop the elevation-requiring items, returning their human-readable
/// descriptions (for the "not done in this run" summary).
fn drop_elevation_required(&mut self) -> Vec<String>;

/// Print the flow-specific "these items need Administrator" preamble.
fn render_elevation_needed(&self);
}

/// Flow-specific wording woven into the gate prompt.
pub(crate) struct GateWording {
/// The command to re-run elevated — `"uffs --update"` / `"uffs
/// --uninstall"`.
pub(crate) rerun_cmd: &'static str,
}

/// What the elevation gate decided for this run.
pub(crate) enum ElevationChoice {
/// Elevated, dry-run, or nothing needs Administrator — plan untouched.
NotNeeded,
/// Continuing without the admin items: they were dropped from the plan;
/// carries their descriptions for the final summary.
ContinueWithout(Vec<String>),
}

/// The elevation gate — the first question, before any mutation. Returns
/// [`ElevationChoice::NotNeeded`] when elevated, under `dry_run`, or when
/// nothing needs Administrator. `assume_yes` continues without asking (a
/// scripted run must never block on a prompt it cannot answer).
///
/// # Errors
///
/// Propagates a prompt I/O error, or aborts (bails) when the user declines.
pub(crate) fn elevation_gate(
plan: &mut impl ElevatablePlan,
dry_run: bool,
assume_yes: bool,
wording: &GateWording,
) -> Result<ElevationChoice> {
if dry_run || !plan.requires_elevation() || uffs_mft::platform::is_elevated() {
return Ok(ElevationChoice::NotNeeded);
}
plan.render_elevation_needed();
if assume_yes {
return Ok(ElevationChoice::ContinueWithout(
plan.drop_elevation_required(),
));
}
let choice = prompt_line(
"\n c = continue without it (the item(s) above are left as-is)\n\
\x20 a = abort\n\n\
Choice [c/A]: ",
)?;
match choice.as_str() {
"c" | "continue" => Ok(ElevationChoice::ContinueWithout(
plan.drop_elevation_required(),
)),
_ => bail!(
"aborted — re-run `{}` from an elevated (Administrator) terminal to include the item(s) above",
wording.rerun_cmd
),
}
}

/// Read a line from stdin after printing `prompt`, trimmed + lowercased.
///
/// # Errors
///
/// Fails if stdout cannot be flushed or stdin cannot be read.
#[expect(clippy::print_stdout, reason = "interactive CLI prompt")]
fn prompt_line(prompt: &str) -> Result<String> {
use std::io::Write as _;
print!("{prompt}");
std::io::stdout().flush().context("flushing the prompt")?;
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.context("reading input")?;
Ok(line.trim().to_ascii_lowercase())
}
Loading
Loading