diff --git a/crates/uffs-cli/src/commands/elevation.rs b/crates/uffs-cli/src/commands/elevation.rs index 3604053c0..5fc93525f 100644 --- a/crates/uffs-cli/src/commands/elevation.rs +++ b/crates/uffs-cli/src/commands/elevation.rs @@ -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}; @@ -34,8 +38,18 @@ 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, } @@ -43,6 +57,15 @@ pub(crate) struct GateWording { 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), @@ -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 /// @@ -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 { if dry_run || !plan.requires_elevation() || uffs_mft::platform::is_elevated() { @@ -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 { + 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 { + binary_choice(plan, wording) +} + +/// The continue-without / abort choice (no in-flow elevation available). +fn binary_choice(plan: &mut impl ElevatablePlan, wording: &GateWording) -> Result { let choice = prompt_line( "\n c = continue without it (the item(s) above are left as-is)\n\ \x20 a = abort\n\n\ diff --git a/crates/uffs-cli/src/commands/uninstall/mod.rs b/crates/uffs-cli/src/commands/uninstall/mod.rs index 436847109..9e54bfa89 100644 --- a/crates/uffs-cli/src/commands/uninstall/mod.rs +++ b/crates/uffs-cli/src/commands/uninstall/mod.rs @@ -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. /// @@ -90,7 +92,7 @@ pub(crate) fn run_uninstall(args: &[String]) -> Result<()> { 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(), + ElevationChoice::NotNeeded | ElevationChoice::ElevateLater => Vec::new(), }; // Wait for the gather (spinner) / run it now (`-v`), then present the @@ -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(); } @@ -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(()) @@ -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), -} - /// 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 { - 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") - } + 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. diff --git a/crates/uffs-cli/src/commands/uninstall/plan.rs b/crates/uffs-cli/src/commands/uninstall/plan.rs index b0fb2bd06..2e22faa99 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan.rs @@ -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. @@ -176,21 +177,9 @@ pub(crate) struct RemovalPlan { pub(crate) groups: Vec, } -impl RemovalPlan { - /// Iterate every item across all groups, in order. - pub(crate) fn items(&self) -> impl Iterator { - 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) } @@ -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 { + fn drop_elevation_required(&mut self) -> Vec { let mut dropped: Vec = Vec::new(); for group in &mut self.groups { group.items.retain(|item| { @@ -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 { + 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: diff --git a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs index 1c1881838..8a65683cb 100644 --- a/crates/uffs-cli/src/commands/uninstall/plan/tests.rs +++ b/crates/uffs-cli/src/commands/uninstall/plan/tests.rs @@ -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, diff --git a/crates/uffs-cli/src/commands/uninstall/render.rs b/crates/uffs-cli/src/commands/uninstall/render.rs index 39e5b5884..e6e00b921 100644 --- a/crates/uffs-cli/src/commands/uninstall/render.rs +++ b/crates/uffs-cli/src/commands/uninstall/render.rs @@ -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 diff --git a/crates/uffs-cli/src/commands/update/mod.rs b/crates/uffs-cli/src/commands/update/mod.rs index 36d351433..df6349c94 100644 --- a/crates/uffs-cli/src/commands/update/mod.rs +++ b/crates/uffs-cli/src/commands/update/mod.rs @@ -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", })? {