@@ -7,11 +7,11 @@ use color_eyre::{
77} ;
88use subprocess:: { Exec , ExitStatus , Redirection } ;
99use thiserror:: Error ;
10- use tracing:: { debug, info} ;
10+ use tracing:: { debug, info, warn} ;
11+ use which:: which;
1112
1213use crate :: installable:: Installable ;
1314use crate :: interface:: NixBuildPassthroughArgs ;
14- use crate :: util:: get_elevation_program;
1515
1616fn 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 ) ]
41107pub 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