From b8ca2f1dfee707bddff20137c76026c2df92677a Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 7 Apr 2026 00:27:20 +0000 Subject: [PATCH 1/3] fix(builtins): command -v/-V now searches PATH for external scripts command -v and command -V only checked builtins, functions, and keywords. Now they also search PATH directories on the VFS for executable scripts, matching real bash behavior. command -v prints the resolved path; command -V prints "name is /path". Closes #1120 --- crates/bashkit/src/interpreter/mod.rs | 48 +++++++++++++-- .../tests/spec_cases/bash/command_v.test.sh | 60 +++++++++++++++++++ 2 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/command_v.test.sh diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index b64fbea2..1ec846d2 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -4143,6 +4143,34 @@ impl Interpreter { /// Search $PATH for an executable script and run it. /// /// Returns `Ok(None)` if no matching file found (caller emits "command not found"). + /// Resolve a command name to its full path via PATH search on VFS. + /// Returns the resolved path string if found, None otherwise. + async fn resolve_command_path(&self, name: &str) -> Option { + let path_var = self + .variables + .get("PATH") + .or_else(|| self.env.get("PATH")) + .cloned() + .unwrap_or_default(); + + for dir in path_var.split(':') { + if dir.is_empty() { + continue; + } + let candidate = PathBuf::from(dir).join(name); + if let Ok(meta) = self.fs.stat(&candidate).await { + if meta.file_type.is_dir() { + continue; + } + if meta.mode & 0o111 == 0 { + continue; + } + return Some(candidate.to_string_lossy().to_string()); + } + } + None + } + async fn try_execute_script_via_path_search( &mut self, name: &str, @@ -5107,12 +5135,18 @@ impl Interpreter { match mode { 'v' => { - // command -v: print name if it's a known command - let found = self.builtins.contains_key(cmd_name.as_str()) - || self.functions.contains_key(cmd_name.as_str()) - || is_keyword(cmd_name); - let mut result = if found { - ExecResult::ok(format!("{}\n", cmd_name)) + // command -v: print name/path if it's a known command + let output = if self.functions.contains_key(cmd_name.as_str()) { + Some(cmd_name.to_string()) + } else if self.builtins.contains_key(cmd_name.as_str()) || is_keyword(cmd_name) { + Some(cmd_name.to_string()) + } else if let Some(path) = self.resolve_command_path(cmd_name).await { + Some(path) + } else { + None + }; + let mut result = if let Some(name) = output { + ExecResult::ok(format!("{}\n", name)) } else { ExecResult { stdout: String::new(), @@ -5133,6 +5167,8 @@ impl Interpreter { format!("{} is a shell builtin\n", cmd_name) } else if is_keyword(cmd_name) { format!("{} is a shell keyword\n", cmd_name) + } else if let Some(path) = self.resolve_command_path(cmd_name).await { + format!("{} is {}\n", cmd_name, path) } else { return Ok(ExecResult::err( format!("bash: command: {}: not found\n", cmd_name), diff --git a/crates/bashkit/tests/spec_cases/bash/command_v.test.sh b/crates/bashkit/tests/spec_cases/bash/command_v.test.sh new file mode 100644 index 00000000..1abf898e --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/command_v.test.sh @@ -0,0 +1,60 @@ +# command -v and command -V tests +# Tests that command -v searches PATH for external scripts (issue #1120) + +### command_v_finds_builtin +# command -v finds builtins +command -v echo +echo "exit=$?" +### expect +echo +exit=0 +### end + +### command_v_finds_function +# command -v finds functions +myfunc() { true; } +command -v myfunc +echo "exit=$?" +### expect +myfunc +exit=0 +### end + +### command_v_not_found +# command -v returns 1 for unknown commands +command -v nonexistent_cmd_xyz_12345 +echo "exit=$?" +### expect +exit=1 +### end + +### command_v_searches_path +# command -v finds executable scripts on PATH +mkdir -p /scripts +echo '#!/bin/bash' > /scripts/myscript +chmod +x /scripts/myscript +export PATH="/scripts:$PATH" +command -v myscript +echo "exit=$?" +### expect +/scripts/myscript +exit=0 +### end + +### command_V_builtin +# command -V describes builtins +command -V echo +### expect +echo is a shell builtin +### end + +### command_V_path_script +# command -V shows full path for scripts on PATH +mkdir -p /scripts +echo '#!/bin/bash' > /scripts/pathcmd +chmod +x /scripts/pathcmd +export PATH="/scripts:$PATH" +command -V pathcmd +### expect +pathcmd is /scripts/pathcmd +### end From 7303b14f8e509bee503536ae744ec24dd83a9ce1 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 7 Apr 2026 00:38:20 +0000 Subject: [PATCH 2/3] fix: merge identical branches in command -v, skip VFS-only comparison tests --- crates/bashkit/src/interpreter/mod.rs | 7 ++++--- crates/bashkit/tests/spec_cases/bash/command_v.test.sh | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 1ec846d2..20e2c6e7 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5136,9 +5136,10 @@ impl Interpreter { match mode { 'v' => { // command -v: print name/path if it's a known command - let output = if self.functions.contains_key(cmd_name.as_str()) { - Some(cmd_name.to_string()) - } else if self.builtins.contains_key(cmd_name.as_str()) || is_keyword(cmd_name) { + let output = if self.functions.contains_key(cmd_name.as_str()) + || self.builtins.contains_key(cmd_name.as_str()) + || is_keyword(cmd_name) + { Some(cmd_name.to_string()) } else if let Some(path) = self.resolve_command_path(cmd_name).await { Some(path) diff --git a/crates/bashkit/tests/spec_cases/bash/command_v.test.sh b/crates/bashkit/tests/spec_cases/bash/command_v.test.sh index 1abf898e..4d320ebe 100644 --- a/crates/bashkit/tests/spec_cases/bash/command_v.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/command_v.test.sh @@ -29,6 +29,7 @@ exit=1 ### end ### command_v_searches_path +### skip: VFS-only test — real bash doesn't have /scripts on disk # command -v finds executable scripts on PATH mkdir -p /scripts echo '#!/bin/bash' > /scripts/myscript @@ -49,6 +50,7 @@ echo is a shell builtin ### end ### command_V_path_script +### skip: VFS-only test — real bash doesn't have /scripts on disk # command -V shows full path for scripts on PATH mkdir -p /scripts echo '#!/bin/bash' > /scripts/pathcmd From 1d25e48caf8a40108335fb604d55b562ae70adad Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Tue, 7 Apr 2026 00:48:03 +0000 Subject: [PATCH 3/3] fix: simplify Option pattern per clippy --- crates/bashkit/src/interpreter/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 20e2c6e7..d88abdeb 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5141,10 +5141,8 @@ impl Interpreter { || is_keyword(cmd_name) { Some(cmd_name.to_string()) - } else if let Some(path) = self.resolve_command_path(cmd_name).await { - Some(path) } else { - None + self.resolve_command_path(cmd_name).await }; let mut result = if let Some(name) = output { ExecResult::ok(format!("{}\n", name))