Skip to content

Commit 3a69ed6

Browse files
new method to make testing work
1 parent 91992f1 commit 3a69ed6

File tree

7 files changed

+178
-172
lines changed

7 files changed

+178
-172
lines changed

src/clean.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use nix::{
1616
use regex::Regex;
1717
use tracing::{Level, debug, info, instrument, span, warn};
1818

19+
use crate::commands::ElevationStrategy;
1920
use crate::{Result, commands::Command, interface};
2021

2122
// Nix impl:
@@ -68,7 +69,7 @@ impl interface::CleanMode {
6869
///
6970
/// Panics if the current user's UID cannot be resolved to a user. For
7071
/// example, if `User::from_uid(uid)` returns `None`.
71-
pub fn run(&self) -> Result<()> {
72+
pub fn run(&self, elevate: ElevationStrategy) -> Result<()> {
7273
use owo_colors::OwoColorize;
7374

7475
let mut profiles = Vec::new();
@@ -86,7 +87,7 @@ impl interface::CleanMode {
8687
}
8788
Self::All(args) => {
8889
if !uid.is_root() {
89-
crate::util::self_elevate();
90+
crate::util::self_elevate(elevate);
9091
}
9192

9293
let paths_to_check = [

src/commands.rs

Lines changed: 123 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ use color_eyre::{
77
};
88
use subprocess::{Exec, ExitStatus, Redirection};
99
use thiserror::Error;
10-
use tracing::{debug, info};
10+
use tracing::{debug, info, warn};
11+
use which::which;
1112

1213
use crate::installable::Installable;
1314
use crate::interface::NixBuildPassthroughArgs;
14-
use crate::util::get_elevation_program;
1515

1616
fn ssh_wrap(cmd: Exec, ssh: Option<&str>) -> Exec {
1717
if let Some(ssh) = ssh {
@@ -37,13 +37,79 @@ pub enum EnvAction {
3737
Remove,
3838
}
3939

40+
/// Define what strategy to use when choosing privilege scalation programs.
41+
#[allow(dead_code)]
42+
#[derive(Debug, Clone, PartialEq, Eq)]
43+
pub enum ElevationStrategy {
44+
Auto,
45+
Prefer(OsString),
46+
Force(&'static str),
47+
}
48+
49+
impl ElevationStrategy {
50+
pub fn resolve(&self) -> Result<OsString> {
51+
match self {
52+
ElevationStrategy::Auto => Self::choice(),
53+
ElevationStrategy::Prefer(program) => {
54+
which(program).map(|x| x.into_os_string()).or_else(|_| {
55+
let auto = Self::choice()?;
56+
warn!(
57+
"{} not found. Using {} instead",
58+
program.to_string_lossy(),
59+
auto.to_string_lossy()
60+
);
61+
Ok(auto)
62+
})
63+
}
64+
ElevationStrategy::Force(program) => Ok(program.into()),
65+
}
66+
}
67+
68+
/// Gets a path to a previlege elevation program based on what is available in the system.
69+
///
70+
/// This funtion checks for the existence of common privilege elevation program names in
71+
/// the `PATH` using the `which` crate and returns a Ok result with the `OsString` of the
72+
/// path to the binary. In the case none of the checked programs are found a Err result is
73+
/// returned.
74+
///
75+
/// The search is done in this order:
76+
///
77+
/// 1. `doas`
78+
/// 1. `sudo`
79+
/// 1. `run0`
80+
/// 1. `pkexec`
81+
///
82+
/// The logic for choosing this order is that a person with `doas` installed is more likely
83+
/// to be using it as their main privilege elevation program.
84+
/// `run0` and `pkexec` are preinstalled in any NixOS system with polkit support installed,
85+
/// so they have been placed lower as it's easier to deactivate sudo than it is to remove
86+
/// `run0`/`pkexec`
87+
///
88+
/// # Returns
89+
///
90+
/// * `Result<OsString>` - The absolute path to the privilege elevation program binary or an error if a
91+
/// program can't be found.
92+
fn choice() -> Result<OsString> {
93+
const STRATEGIES: [&str; 4] = ["doas", "sudo", "run0", "pkexec"];
94+
95+
for strategy in STRATEGIES {
96+
if let Ok(path) = which(strategy) {
97+
debug!(?path, "{strategy} path found");
98+
return Ok(path.into_os_string());
99+
}
100+
}
101+
102+
Err(eyre::eyre!("No elevation strategy found"))
103+
}
104+
}
105+
40106
#[derive(Debug)]
41107
pub struct Command {
42108
dry: bool,
43109
message: Option<String>,
44110
command: OsString,
45111
args: Vec<OsString>,
46-
elevate: bool,
112+
elevate: Option<ElevationStrategy>,
47113
ssh: Option<String>,
48114
show_output: bool,
49115
env_vars: HashMap<String, EnvAction>,
@@ -56,7 +122,7 @@ impl Command {
56122
message: None,
57123
command: command.as_ref().to_os_string(),
58124
args: vec![],
59-
elevate: false,
125+
elevate: None,
60126
ssh: None,
61127
show_output: false,
62128
env_vars: HashMap::new(),
@@ -65,7 +131,7 @@ impl Command {
65131

66132
/// Set whether to run the command with elevated privileges.
67133
#[must_use]
68-
pub fn elevate(mut self, elevate: bool) -> Self {
134+
pub fn elevate(mut self, elevate: Option<ElevationStrategy>) -> Self {
69135
self.elevate = elevate;
70136
self
71137
}
@@ -164,7 +230,7 @@ impl Command {
164230
}
165231

166232
// Only propagate HOME for non-elevated commands
167-
if !self.elevate {
233+
if self.elevate.is_none() {
168234
if let Ok(home) = std::env::var("HOME") {
169235
self.env_vars
170236
.insert("HOME".to_string(), EnvAction::Set(home));
@@ -222,63 +288,31 @@ impl Command {
222288
cmd
223289
}
224290

291+
/// Creates a Exec that contains elevates the program with proper environment handling.
292+
///
293+
/// Panics: If called when `self.elevate` is `None`
225294
fn build_sudo_cmd(&self) -> Exec {
226-
let mut cmd = Exec::cmd(get_elevation_program().unwrap());
227-
228-
// Collect variables to preserve for sudo
229-
let mut preserve_vars = Vec::new();
230-
let mut explicit_env_vars = HashMap::new();
231-
232-
for (key, action) in &self.env_vars {
233-
match action {
234-
EnvAction::Set(value) => {
235-
explicit_env_vars.insert(key.clone(), value.clone());
236-
}
237-
EnvAction::Preserve => {
238-
preserve_vars.push(key.as_str());
239-
}
240-
EnvAction::Remove => {
241-
// Explicitly don't add to preserve_vars
242-
}
243-
}
244-
}
245-
246-
// Platform-agnostic handling for preserve-env
247-
if !preserve_vars.is_empty() {
248-
// NH_SUDO_PRESERVE_ENV: set to "0" to disable --preserve-env, "1" to force, unset defaults to force
249-
let preserve_env_override = std::env::var("NH_SUDO_PRESERVE_ENV").ok();
250-
match preserve_env_override.as_deref() {
251-
Some("0") => {
252-
cmd = cmd.arg("--set-home");
253-
}
254-
Some("1") | None => {
255-
cmd = cmd.args(&[
256-
"--set-home",
257-
&format!("--preserve-env={}", preserve_vars.join(",")),
258-
]);
259-
}
260-
_ => {
261-
cmd = cmd.args(&[
262-
"--set-home",
263-
&format!("--preserve-env={}", preserve_vars.join(",")),
264-
]);
265-
}
266-
}
267-
} else if cfg!(target_os = "macos") {
268-
cmd = cmd.arg("--set-home");
269-
}
295+
let elevation_program = self.elevate.as_ref().unwrap().resolve().unwrap();
296+
let mut cmd = Exec::cmd(&elevation_program);
270297

271298
// Use NH_SUDO_ASKPASS program for sudo if present
272-
if let Ok(askpass) = std::env::var("NH_SUDO_ASKPASS") {
273-
cmd = cmd.env("SUDO_ASKPASS", askpass).arg("-A");
299+
if elevation_program.to_string_lossy().contains("sudo") {
300+
if let Ok(askpass) = std::env::var("NH_SUDO_ASKPASS") {
301+
cmd = cmd.env("SUDO_ASKPASS", askpass).arg("-A");
302+
}
274303
}
275304

276305
// Insert 'env' command to explicitly pass environment variables to the elevated command
277-
if !explicit_env_vars.is_empty() {
278-
cmd = cmd.arg("env");
279-
for (key, value) in explicit_env_vars {
280-
cmd = cmd.arg(format!("{key}={value}"));
281-
}
306+
cmd = cmd.arg("env");
307+
for arg in self.env_vars.iter().flat_map(|(key, action)| match action {
308+
EnvAction::Set(value) => Some(format!("{key}={value}")),
309+
EnvAction::Preserve => match std::env::var(key) {
310+
Ok(value) => Some(format!("{key}={value}")),
311+
Err(_) => None,
312+
},
313+
EnvAction::Remove => None,
314+
}) {
315+
cmd = cmd.arg(arg);
282316
}
283317

284318
cmd
@@ -289,13 +323,15 @@ impl Command {
289323
/// # Errors
290324
///
291325
/// Returns an error if the current executable path cannot be determined or sudo command cannot be built.
292-
pub fn self_elevate_cmd() -> Result<std::process::Command> {
326+
pub fn self_elevate_cmd(strategy: ElevationStrategy) -> Result<std::process::Command> {
293327
// Get the current executable path
294328
let current_exe =
295329
std::env::current_exe().context("Failed to get current executable path")?;
296330

297331
// Self-elevation with proper environment handling
298-
let cmd_builder = Self::new(&current_exe).elevate(true).with_required_env();
332+
let cmd_builder = Self::new(&current_exe)
333+
.elevate(Some(strategy))
334+
.with_required_env();
299335

300336
let sudo_exec = cmd_builder.build_sudo_cmd();
301337

@@ -330,7 +366,7 @@ impl Command {
330366
///
331367
/// Panics if the command result is unexpectedly None.
332368
pub fn run(&self) -> Result<()> {
333-
let cmd = if self.elevate {
369+
let cmd = if self.elevate.is_some() {
334370
self.build_sudo_cmd().arg(&self.command).args(&self.args)
335371
} else {
336372
self.apply_env_to_exec(Exec::cmd(&self.command).args(&self.args))
@@ -587,7 +623,7 @@ mod tests {
587623
assert!(!cmd.dry);
588624
assert!(cmd.message.is_none());
589625
assert!(cmd.args.is_empty());
590-
assert!(!cmd.elevate);
626+
assert!(cmd.elevate.is_none());
591627
assert!(cmd.ssh.is_none());
592628
assert!(!cmd.show_output);
593629
assert!(cmd.env_vars.is_empty());
@@ -597,16 +633,16 @@ mod tests {
597633
fn test_command_builder_pattern() {
598634
let cmd = Command::new("test")
599635
.dry(true)
600-
.elevate(true)
601636
.show_output(true)
637+
.elevate(Some(ElevationStrategy::Force("sudo")))
602638
.ssh(Some("host".to_string()))
603639
.message("test message")
604640
.arg("arg1")
605641
.args(["arg2", "arg3"]);
606642

607643
assert!(cmd.dry);
608-
assert!(cmd.elevate);
609644
assert!(cmd.show_output);
645+
assert_eq!(cmd.elevate, Some(ElevationStrategy::Force("sudo")));
610646
assert_eq!(cmd.ssh, Some("host".to_string()));
611647
assert_eq!(cmd.message, Some("test message".to_string()));
612648
assert_eq!(
@@ -781,7 +817,7 @@ mod tests {
781817

782818
#[test]
783819
fn test_build_sudo_cmd_basic() {
784-
let cmd = Command::new("test");
820+
let cmd = Command::new("test").elevate(Some(ElevationStrategy::Force("sudo")));
785821
let sudo_exec = cmd.build_sudo_cmd();
786822

787823
// Platform-agnostic: 'sudo' may not be the first token if env vars are injected (e.g., NH_SUDO_ASKPASS).
@@ -793,29 +829,25 @@ mod tests {
793829
#[test]
794830
#[serial]
795831
fn test_build_sudo_cmd_with_preserve_vars() {
796-
let cmd = Command::new("test").preserve_envs(["VAR1", "VAR2"]);
832+
let _var1_guard = EnvGuard::new("VAR1", "1");
833+
let _var2_guard = EnvGuard::new("VAR2", "2");
834+
835+
let cmd = Command::new("test")
836+
.preserve_envs(["VAR1", "VAR2"])
837+
.elevate(Some(ElevationStrategy::Force("sudo")));
797838

798839
let sudo_exec = cmd.build_sudo_cmd();
799840
let cmdline = sudo_exec.to_cmdline_lossy();
800841

801-
// NH_SUDO_PRESERVE_ENV: set to "0" to disable --preserve-env, "1" to force, unset defaults to force
802-
let preserve_env_override = std::env::var("NH_SUDO_PRESERVE_ENV").ok();
803-
match preserve_env_override.as_deref() {
804-
Some("0") => {
805-
assert!(!cmdline.contains("--preserve-env="));
806-
}
807-
Some("1") | None | _ => {
808-
assert!(cmdline.contains("--preserve-env="));
809-
assert!(cmdline.contains("VAR1"));
810-
assert!(cmdline.contains("VAR2"));
811-
}
812-
}
842+
assert!(cmdline.contains("env"));
843+
assert!(cmdline.contains("VAR1=1"));
844+
assert!(cmdline.contains("VAR2=2"));
813845
}
814846

815847
#[test]
816848
#[serial]
817849
fn test_build_sudo_cmd_with_set_vars() {
818-
let mut cmd = Command::new("test");
850+
let mut cmd = Command::new("test").elevate(Some(ElevationStrategy::Force("sudo")));
819851
cmd.env_vars.insert(
820852
"TEST_VAR".to_string(),
821853
EnvAction::Set("test_value".to_string()),
@@ -832,7 +864,10 @@ mod tests {
832864
#[test]
833865
#[serial]
834866
fn test_build_sudo_cmd_with_remove_vars() {
835-
let mut cmd = Command::new("test");
867+
let _preserve_guard = EnvGuard::new("VAR_TO_PRESERVE", "preserve");
868+
let _remove_guard = EnvGuard::new("VAR_TO_REMOVE", "remove");
869+
870+
let mut cmd = Command::new("test").elevate(Some(ElevationStrategy::Force("sudo")));
836871
cmd.env_vars
837872
.insert("VAR_TO_PRESERVE".to_string(), EnvAction::Preserve);
838873
cmd.env_vars
@@ -841,19 +876,17 @@ mod tests {
841876
let sudo_exec = cmd.build_sudo_cmd();
842877
let cmdline = sudo_exec.to_cmdline_lossy();
843878

844-
// Should preserve only the Preserve variable, not the Remove one
845-
if cmdline.contains("--preserve-env=") {
846-
assert!(cmdline.contains("VAR_TO_PRESERVE"));
847-
assert!(!cmdline.contains("VAR_TO_REMOVE"));
848-
}
879+
assert!(cmdline.contains("env"));
880+
assert!(cmdline.contains("VAR_TO_PRESERVE=preserve"));
881+
assert!(!cmdline.contains("VAR_TO_REMOVE"));
849882
}
850883

851884
#[test]
852885
#[serial]
853886
fn test_build_sudo_cmd_with_askpass() {
854887
let _guard = EnvGuard::new("NH_SUDO_ASKPASS", "/path/to/askpass");
855888

856-
let cmd = Command::new("test");
889+
let cmd = Command::new("test").elevate(Some(ElevationStrategy::Force("sudo")));
857890
let sudo_exec = cmd.build_sudo_cmd();
858891
let cmdline = sudo_exec.to_cmdline_lossy();
859892

@@ -864,7 +897,9 @@ mod tests {
864897
#[test]
865898
#[serial]
866899
fn test_build_sudo_cmd_env_added_once() {
867-
let mut cmd = Command::new("test");
900+
let _preserve_guard = EnvGuard::new("PRESERVE_VAR", "preserve");
901+
902+
let mut cmd = Command::new("test").elevate(Some(ElevationStrategy::Force("sudo")));
868903
cmd.env_vars.insert(
869904
"TEST_VAR1".to_string(),
870905
EnvAction::Set("value1".to_string()),
@@ -893,6 +928,8 @@ mod tests {
893928
// Should contain our explicit environment variables
894929
assert!(cmdline.contains("TEST_VAR1=value1"));
895930
assert!(cmdline.contains("TEST_VAR2=value2"));
931+
// and the preserved too
932+
assert!(cmdline.contains("PRESERVE_VAR=preserve"));
896933
}
897934

898935
#[test]

0 commit comments

Comments
 (0)