diff --git a/brush-core/src/builtins/command.rs b/brush-core/src/builtins/command.rs index 7f7c906e..95f6e356 100644 --- a/brush-core/src/builtins/command.rs +++ b/brush-core/src/builtins/command.rs @@ -1,7 +1,7 @@ use clap::Parser; use std::{fmt::Display, io::Write, path::Path}; -use crate::{builtins, commands, error, shell, sys::fs::PathExt, ExecutionResult}; +use crate::{builtins, commands, error, shell, sys, sys::fs::PathExt, ExecutionResult}; /// Directly invokes an external command, without going through typical search order. #[derive(Parser)] @@ -36,14 +36,12 @@ impl builtins::Command for CommandCommand { ) -> Result { // Silently exit if no command was provided. if let Some(command_name) = self.command() { - if self.use_default_path { - return error::unimp("command -p"); - } - if self.print_description || self.print_verbose_description { - if let Some(found_cmd) = - Self::try_find_command(context.shell, command_name.as_str()) - { + if let Some(found_cmd) = Self::try_find_command( + context.shell, + command_name.as_str(), + self.use_default_path, + ) { if self.print_description { writeln!(context.stdout(), "{found_cmd}")?; } else { @@ -64,7 +62,8 @@ impl builtins::Command for CommandCommand { Ok(builtins::ExitCode::Custom(1)) } } else { - self.execute_command(context, command_name).await + self.execute_command(context, command_name, self.use_default_path) + .await } } else { Ok(builtins::ExitCode::Success) @@ -88,7 +87,11 @@ impl Display for FoundCommand { impl CommandCommand { #[allow(clippy::unwrap_in_result)] - fn try_find_command(shell: &mut shell::Shell, command_name: &str) -> Option { + fn try_find_command( + shell: &mut shell::Shell, + command_name: &str, + use_default_path: bool, + ) -> Option { // Look in path. if command_name.contains(std::path::MAIN_SEPARATOR) { let candidate_path = shell.get_absolute_path(Path::new(command_name)); @@ -110,9 +113,17 @@ impl CommandCommand { } } - shell - .find_first_executable_in_path_using_cache(command_name) - .map(|path| FoundCommand::External(path.to_string_lossy().to_string())) + if use_default_path { + let dirs = sys::fs::get_default_standard_utils_paths(); + shell + .find_executables_in(dirs.iter(), command_name) + .first() + .map(|path| FoundCommand::External(path.to_string_lossy().to_string())) + } else { + shell + .find_first_executable_in_path_using_cache(command_name) + .map(|path| FoundCommand::External(path.to_string_lossy().to_string())) + } } } @@ -120,10 +131,17 @@ impl CommandCommand { &self, mut context: commands::ExecutionContext<'_>, command_name: &str, + use_default_path: bool, ) -> Result { command_name.clone_into(&mut context.command_name); let command_and_args = self.command_and_args.iter().map(|arg| arg.into()).collect(); + let path_dirs = if use_default_path { + Some(sys::fs::get_default_standard_utils_paths()) + } else { + None + }; + // We do not have an existing process group to place this into. let mut pgid = None; @@ -134,6 +152,7 @@ impl CommandCommand { &mut pgid, command_and_args, false, /* use functions? */ + path_dirs, ) .await? { diff --git a/brush-core/src/commands.rs b/brush-core/src/commands.rs index ee3edbd1..c75c4b34 100644 --- a/brush-core/src/commands.rs +++ b/brush-core/src/commands.rs @@ -273,6 +273,7 @@ pub(crate) async fn execute( process_group_id: &mut Option, args: Vec, use_functions: bool, + path_dirs: Option>, ) -> Result { if !cmd_context.command_name.contains(std::path::MAIN_SEPARATOR) { let builtin = cmd_context @@ -307,10 +308,19 @@ pub(crate) async fn execute( } } - if let Some(path) = cmd_context - .shell - .find_first_executable_in_path_using_cache(&cmd_context.command_name) - { + let path = if let Some(path_dirs) = path_dirs { + cmd_context + .shell + .find_executables_in(path_dirs.iter(), &cmd_context.command_name) + .first() + .cloned() + } else { + cmd_context + .shell + .find_first_executable_in_path_using_cache(&cmd_context.command_name) + }; + + if let Some(path) = path { let resolved_path = path.to_string_lossy(); execute_external_command( cmd_context, diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs index f64f33d6..9014ed9b 100644 --- a/brush-core/src/interp.rs +++ b/brush-core/src/interp.rs @@ -1116,6 +1116,7 @@ impl ExecuteInPipeline for ast::SimpleCommand { &mut context.process_group_id, args, true, /* use functions? */ + None, ) .await; diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs index bb6eacae..a0cd2712 100644 --- a/brush-core/src/shell.rs +++ b/brush-core/src/shell.rs @@ -540,12 +540,9 @@ impl Shell { // PATH (if not already set) #[cfg(unix)] if !self.env.is_set("PATH") { - self.env.set_global( - "PATH", - ShellVariable::new( - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(), - ), - )?; + let default_path_str = sys::fs::get_default_executable_search_paths().join(":"); + self.env + .set_global("PATH", ShellVariable::new(default_path_str.into()))?; } // PIPESTATUS @@ -1291,12 +1288,33 @@ impl Shell { /// # Arguments /// /// * `required_glob_pattern` - The glob pattern to match against. - #[allow(clippy::manual_flatten)] pub fn find_executables_in_path(&self, required_glob_pattern: &str) -> Vec { + self.find_executables_in( + self.env + .get_str("PATH", self) + .unwrap_or_default() + .split(':'), + required_glob_pattern, + ) + } + + /// Finds executables in the given paths, matching the given glob pattern. + /// + /// # Arguments + /// + /// * `paths` - The paths to search in + /// * `required_glob_pattern` - The glob pattern to match against. + #[allow(clippy::manual_flatten)] + pub fn find_executables_in>( + &self, + paths: impl Iterator, + required_glob_pattern: &str, + ) -> Vec { let is_executable = |path: &Path| path.is_file() && path.executable(); let mut executables = vec![]; - for dir_str in self.get_env_str("PATH").unwrap_or_default().split(':') { + for dir_str in paths { + let dir_str = dir_str.as_ref(); let pattern = patterns::Pattern::from(std::format!("{dir_str}/{required_glob_pattern}")) .set_extended_globbing(self.options.extended_globbing) diff --git a/brush-core/src/sys/stubs/fs.rs b/brush-core/src/sys/stubs/fs.rs index 22221cd7..2ed31d69 100644 --- a/brush-core/src/sys/stubs/fs.rs +++ b/brush-core/src/sys/stubs/fs.rs @@ -52,3 +52,11 @@ pub(crate) trait StubMetadataExt { } impl StubMetadataExt for std::fs::Metadata {} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + vec![] +} + +pub(crate) fn get_default_standard_utils_paths() -> Vec { + vec![] +} diff --git a/brush-core/src/sys/unix/fs.rs b/brush-core/src/sys/unix/fs.rs index c3710c4a..e70259ff 100644 --- a/brush-core/src/sys/unix/fs.rs +++ b/brush-core/src/sys/unix/fs.rs @@ -1,6 +1,19 @@ +use std::os::unix::ffi::OsStringExt; use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::path::Path; +const DEFAULT_EXECUTABLE_SEARCH_PATHS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", +]; + +const DEFAULT_STANDARD_UTILS_PATHS: &[&str] = + &["/bin", "/usr/bin", "/sbin", "/usr/sbin", "/etc", "/usr/etc"]; + impl crate::sys::fs::PathExt for Path { fn readable(&self) -> bool { nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok() @@ -56,3 +69,78 @@ fn try_get_file_type(path: &Path) -> Option { fn try_get_file_mode(path: &Path) -> Option { path.metadata().map(|metadata| metadata.mode()).ok() } + +pub(crate) fn get_default_executable_search_paths() -> Vec { + DEFAULT_EXECUTABLE_SEARCH_PATHS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +/// Retrieves the platform-specific set of paths that should contain standard system +/// utilities. Used by `command -p`, for example. +pub(crate) fn get_default_standard_utils_paths() -> Vec { + // + // Try to call confstr(_CS_PATH). If that fails, can't find a string value, or + // finds an empty string, then we'll fall back to hard-coded defaults. + // + + if let Ok(Some(cs_path)) = confstr_cs_path() { + if !cs_path.is_empty() { + return cs_path.split(':').map(|s| s.to_string()).collect(); + } + } + + DEFAULT_STANDARD_UTILS_PATHS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +fn confstr_cs_path() -> Result, std::io::Error> { + let value = confstr(nix::libc::_CS_PATH)?; + + if let Some(value) = value { + let value_str = value + .into_string() + .map_err(|_err| std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid data"))?; + Ok(Some(value_str)) + } else { + Ok(None) + } +} + +/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which +/// indicates where all the POSIX.2 standard utilities can be found. +/// +/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix +/// or similar. Until that exists, we accept the need to make the unsafe call directly. +fn confstr(name: nix::libc::c_int) -> Result, std::io::Error> { + let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) }; + + // When confstr returns 0, it either means there's no value associated with _CS_PATH, or + // _CS_PATH is considered invalid (and not present) on this platform. In both cases, we + // treat it as a non-existent value and return None. + if required_size == 0 { + return Ok(None); + } + + let mut buffer = Vec::::with_capacity(required_size); + + // NOTE: Writing `c_char` (i8 or u8 depending on the platform) into `Vec` is fine, + // as i8 and u8 have compatible representations, + // and Rust does not support platforms where `c_char` is not 8-bit wide. + let final_size = + unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) }; + + if final_size == 0 { + return Err(std::io::Error::last_os_error()); + } + + unsafe { buffer.set_len(final_size) }; + + // The last byte is a null terminator. + buffer.pop(); + + Ok(Some(std::ffi::OsString::from_vec(buffer))) +} diff --git a/brush-shell/tests/cases/builtins/command.yaml b/brush-shell/tests/cases/builtins/command.yaml index 0823c17e..7d3efe00 100644 --- a/brush-shell/tests/cases/builtins/command.yaml +++ b/brush-shell/tests/cases/builtins/command.yaml @@ -31,6 +31,16 @@ cases: echo "[/usr/bin/non-existent]" command -v /usr/bin/non-existent || echo "2. Not found" + - name: "command -v -p" + stdin: | + unset PATH + + echo "[no -p]" + command -v cat + + echo "[-p]" + command -v -p cat + - name: "command -v with full paths" skip: true # TODO: investigate why this fails on arch linux stdin: | @@ -57,3 +67,9 @@ cases: - name: "command with --help" stdin: | command ls --help + + - name: "command with -p" + stdin: | + unset PATH + + command -p -- ls