Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 86 additions & 14 deletions crates/uffs-cli/src/commands/elevation.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2025-2026 SKY, LLC.

//! Shared elevation gate for the mutating flows.
//! Shared elevation gate for the mutating flows (`uffs --uninstall`,
//! `uffs --update`).
//!
//! 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).
//! Administrator and decides once. Surfacing this before any mutation is what
//! keeps a flow from failing halfway through. Both flows drive this identically
//! so their elevation UX stays in lockstep.
//!
//! 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.
//! The one difference is whether the flow can elevate **in place**:
//!
//! - `uffs --uninstall` has a one-shot elevated helper it spawns via a single
//! UAC prompt at removal time, so on Windows it offers a 3-way choice —
//! elevate now / continue without / abort (`offer_inflow_elevation = true`).
//! - `uffs --update` has no in-flow elevation (and `winget` itself *refuses* to
//! upgrade a user package elevated), so its elevated path is simply "re-run
//! elevated" and the choice stays binary (`offer_inflow_elevation = false`).
//!
//! Non-Windows has no UAC to request, so the choice is always binary there.

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

Expand All @@ -34,15 +38,34 @@ pub(crate) trait ElevatablePlan {

/// Flow-specific wording woven into the gate prompt.
pub(crate) struct GateWording {
/// The command to re-run elevated — `"uffs --update"` / `"uffs
/// --uninstall"`.
/// The mutating action — `"removal"` / `"update"` — used in
/// "elevate at <action> time" (only on the in-flow-elevation prompt).
#[cfg_attr(
not(windows),
expect(
dead_code,
reason = "read only by the Windows in-flow-elevation prompt"
)
)]
pub(crate) action: &'static str,
/// The command to re-run elevated — `"uffs --uninstall"` / `"uffs
/// --update"`.
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,
/// Windows in-flow elevation: keep the admin items; the mutating step
/// routes them through a one-shot elevated helper (a single UAC
/// prompt). Produced only when the caller offers in-flow elevation AND
/// on Windows.
#[cfg_attr(
not(windows),
expect(dead_code, reason = "constructed only on the Windows in-flow-UAC path")
)]
ElevateLater,
/// Continuing without the admin items: they were dropped from the plan;
/// carries their descriptions for the final summary.
ContinueWithout(Vec<String>),
Expand All @@ -51,7 +74,9 @@ pub(crate) enum ElevationChoice {
/// 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).
/// scripted run must never block on a prompt it cannot answer, nor trigger a
/// surprise UAC). `offer_inflow_elevation` enables the Windows 3-way choice for
/// flows that can elevate in place.
///
/// # Errors
///
Expand All @@ -60,6 +85,7 @@ pub(crate) fn elevation_gate(
plan: &mut impl ElevatablePlan,
dry_run: bool,
assume_yes: bool,
offer_inflow_elevation: bool,
wording: &GateWording,
) -> Result<ElevationChoice> {
if dry_run || !plan.requires_elevation() || uffs_mft::platform::is_elevated() {
Expand All @@ -71,6 +97,52 @@ pub(crate) fn elevation_gate(
plan.drop_elevation_required(),
));
}
platform_elevation_choice(plan, offer_inflow_elevation, wording)
}

/// Windows: the 3-way choice when the flow can elevate in place (`e` records
/// the decision — the single UAC prompt appears later, when the mutating step
/// runs); otherwise the binary continue-without / abort.
#[cfg(windows)]
fn platform_elevation_choice(
plan: &mut impl ElevatablePlan,
offer_inflow_elevation: bool,
wording: &GateWording,
) -> Result<ElevationChoice> {
if !offer_inflow_elevation {
return binary_choice(plan, wording);
}
let choice = prompt_line(&format!(
"\n e = elevate at {} time (Windows shows one UAC prompt)\n\
\x20 c = continue without it (the item(s) above are left as-is)\n\
\x20 a = abort\n\n\
Choice [e/c/A]: ",
wording.action,
))?;
match choice.as_str() {
"e" | "elevate" => Ok(ElevationChoice::ElevateLater),
"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
),
}
}

/// Non-Windows: there is no UAC to request, so the choice is always binary.
#[cfg(not(windows))]
fn platform_elevation_choice(
plan: &mut impl ElevatablePlan,
_offer_inflow_elevation: bool,
wording: &GateWording,
) -> Result<ElevationChoice> {
binary_choice(plan, wording)
}

/// The continue-without / abort choice (no in-flow elevation available).
fn binary_choice(plan: &mut impl ElevatablePlan, wording: &GateWording) -> Result<ElevationChoice> {
let choice = prompt_line(
"\n c = continue without it (the item(s) above are left as-is)\n\
\x20 a = abort\n\n\
Expand Down
101 changes: 21 additions & 80 deletions crates/uffs-cli/src/commands/uninstall/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ use anyhow::{Context as _, Result, bail};
use args::UninstallArgs;
use plan::{PlanTarget, RemovalPlan};

use crate::commands::elevation::{self, ElevatablePlan as _, ElevationChoice};

/// Entry point for `uffs --uninstall`. `args` is every token after the
/// `--uninstall` command token.
///
Expand Down Expand Up @@ -90,7 +92,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> {
let gate = elevation_gate(&parsed, &mut removal_plan)?;
let skipped_elevation: Vec<String> = match &gate {
ElevationChoice::ContinueWithout(items) => items.clone(),
ElevationChoice::NotNeeded | ElevationChoice::ElevateAtRemoval => Vec::new(),
ElevationChoice::NotNeeded | ElevationChoice::ElevateLater => Vec::new(),
};

// Wait for the gather (spinner) / run it now (`-v`), then present the
Expand Down Expand Up @@ -121,7 +123,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> {
render::print_extra_table(&gathered.strays);
render::print_plan(&removal_plan, stray_plan);
render::print_skipped_elevation(&skipped_elevation);
if matches!(gate, ElevationChoice::ElevateAtRemoval) {
if matches!(gate, ElevationChoice::ElevateLater) {
render::print_uac_note();
}

Expand Down Expand Up @@ -161,7 +163,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> {
&removal_plan,
stray_plan,
remove_strays,
matches!(gate, ElevationChoice::ElevateAtRemoval),
matches!(gate, ElevationChoice::ElevateLater),
broker_remains,
);
Ok(())
Expand Down Expand Up @@ -285,88 +287,27 @@ fn binary_dir_bytes(dir: &std::path::Path, stems: &[String]) -> u64 {
.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<String>),
}

/// 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).
/// Delegates to the shared [`elevation`] gate. The broker (its `LocalSystem`
/// service) is the only admin-only part, and uninstall CAN elevate **in place**
/// — a one-shot elevated helper at removal time (one UAC prompt) — so it offers
/// the Windows 3-way choice (`offer_inflow_elevation = true`). Skipped when
/// elevated, under `--dry-run`, or when nothing needs Administrator; `--yes`
/// continues without asking (a scripted run never triggers a surprise UAC).
fn elevation_gate(
parsed: &UninstallArgs,
removal_plan: &mut RemovalPlan,
) -> Result<ElevationChoice> {
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<ElevationChoice> {
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<ElevationChoice> {
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")
}
elevation::elevation_gate(
removal_plan,
parsed.dry_run,
parsed.assume_yes,
true,
&elevation::GateWording {
action: "removal",
rerun_cmd: "uffs --uninstall",
},
)
}

/// Read one line of input for a multi-choice prompt, trimmed and lowercased.
Expand Down
38 changes: 23 additions & 15 deletions crates/uffs-cli/src/commands/uninstall/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use super::args::{UninstallArgs, UninstallScope};
use super::inventory::{ArtifactKind, BrokerServiceState, Inventory};
#[cfg(windows)]
use super::sweep::StrayHit;
use crate::commands::elevation::ElevatablePlan;
use crate::commands::update::model::{Channel, Component, DetectionReport, InstallRoot, Scope};

/// The `WinGet` package id UFFS publishes under.
Expand Down Expand Up @@ -176,21 +177,9 @@ pub(crate) struct RemovalPlan {
pub(crate) groups: Vec<PlanGroup>,
}

impl RemovalPlan {
/// Iterate every item across all groups, in order.
pub(crate) fn items(&self) -> impl Iterator<Item = &PlanItem> {
self.groups.iter().flat_map(|group| &group.items)
}

/// Total bytes the plan would reclaim.
pub(crate) fn total_bytes(&self) -> u64 {
self.items()
.map(|item| item.bytes)
.fold(0, u64::saturating_add)
}

impl ElevatablePlan for RemovalPlan {
/// Whether any item requires Administrator.
pub(crate) fn requires_elevation(&self) -> bool {
fn requires_elevation(&self) -> bool {
self.items().any(|item| item.needs_elevation)
}

Expand All @@ -199,7 +188,7 @@ impl RemovalPlan {
/// 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<String> {
fn drop_elevation_required(&mut self) -> Vec<String> {
let mut dropped: Vec<String> = Vec::new();
for group in &mut self.groups {
group.items.retain(|item| {
Expand All @@ -214,6 +203,25 @@ impl RemovalPlan {
dropped
}

/// The non-elevated preamble listing the admin-only items (the broker).
fn render_elevation_needed(&self) {
super::render::print_elevation_gate(self);
}
}

impl RemovalPlan {
/// Iterate every item across all groups, in order.
pub(crate) fn items(&self) -> impl Iterator<Item = &PlanItem> {
self.groups.iter().flat_map(|group| &group.items)
}

/// Total bytes the plan would reclaim.
pub(crate) fn total_bytes(&self) -> u64 {
self.items()
.map(|item| item.bytes)
.fold(0, u64::saturating_add)
}

/// 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:
Expand Down
1 change: 1 addition & 0 deletions crates/uffs-cli/src/commands/uninstall/plan/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use std::path::PathBuf;
#[cfg(windows)]
use super::build_stray_plan;
use super::{PlanTarget, RemovalPlan, build_plan};
use crate::commands::elevation::ElevatablePlan as _;
use crate::commands::uninstall::args::{UninstallArgs, UninstallScope};
use crate::commands::uninstall::inventory::{
ArtifactDir, ArtifactKind, BrokerServiceState, Inventory,
Expand Down
1 change: 1 addition & 0 deletions crates/uffs-cli/src/commands/uninstall/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use super::remove::{ItemStatus, RemovalOutcome};
use super::resolve_order::{ResolutionState, StemResolution};
#[cfg(windows)]
use super::sweep::StrayHit;
use crate::commands::elevation::ElevatablePlan as _;
use crate::commands::update::model::{Channel, Scope};

/// Print the running build's version + git commit at the top of an uninstall
Expand Down
6 changes: 5 additions & 1 deletion crates/uffs-cli/src/commands/update/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,12 @@ fn run_automatic_update(report: &DetectionReport, verbose: bool) -> Result<()> {
// (continue-without / abort) — so nothing fails mid-swap. The per-root
// elevation model lives in `plan`; the gate in `commands::elevation`.
let mut plan = plan::UpdatePlan::build(report, &latest);
// Update has no in-flow elevation (winget refuses a user package elevated),
// so `offer_inflow_elevation = false` → the gate is the binary
// continue-without / abort.
if let elevation::ElevationChoice::ContinueWithout(dropped) =
elevation::elevation_gate(&mut plan, false, false, &elevation::GateWording {
elevation::elevation_gate(&mut plan, false, false, false, &elevation::GateWording {
action: "update",
rerun_cmd: "uffs --update",
})?
{
Expand Down
Loading