Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ pub(crate) enum Error<'src> {
ExcessInvocations {
invocations: usize,
},
ExecutableNotFound {
name: String,
suggestion: Option<Suggestion<'src>>,
},
ExpectedSubmoduleButFoundRecipe {
path: String,
},
Expand Down Expand Up @@ -409,6 +413,12 @@ impl ColorDisplay for Error<'_> {
ExcessInvocations { invocations } => {
write!(f, "Expected 1 command-line recipe invocation but found {invocations}.")?;
},
ExecutableNotFound { name, suggestion } => {
write!(f, "Could not find executable `{name}` in PATH")?;
if let Some(suggestion) = suggestion {
write!(f, "\n{suggestion}")?;
}
}
ExpectedSubmoduleButFoundRecipe { path } => {
write!(f, "Expected submodule at `{path}` but found recipe.")?;
},
Expand Down
7 changes: 6 additions & 1 deletion src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,16 @@ impl<'src, 'run> Evaluator<'src, 'run> {
}

pub(crate) fn run_command(&self, command: &str, args: &[&str]) -> Result<String, OutputError> {
let working_dir = self.context.working_directory();
let mut cmd = self
.context
.module
.settings
.shell_command(self.context.config);
.shell_command(self.context.config, &working_dir)
.map_err(|error| OutputError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("{}", error.color_display(Color::never())),
)))?;

cmd
.arg(command)
Expand Down
6 changes: 5 additions & 1 deletion src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ impl Executor<'_> {
) -> RunResult<'src, Command> {
match self {
Self::Command(interpreter) => {
let mut command = Command::new(&interpreter.command.cooked);
// Resolve executable to absolute path using PATH
let working_dir = working_directory.unwrap_or_else(|| Path::new("."));
let resolved = which::resolve_executable(&interpreter.command.cooked, working_dir)?;

let mut command = Command::new(resolved);

if let Some(working_directory) = working_directory {
command.current_dir(working_directory);
Expand Down
2 changes: 1 addition & 1 deletion src/justfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ impl<'src> Justfile<'src> {
binary, arguments, ..
} => {
let mut command = if config.shell_command {
let mut command = self.settings.shell_command(config);
let mut command = self.settings.shell_command(config, &search.working_directory)?;
command.arg(binary);
command
} else {
Expand Down
10 changes: 8 additions & 2 deletions src/platform/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ impl PlatformInterface for Platform {

Cow::Owned(cygpath.output_guard_stdout()?)
} else {
// …otherwise use it as-is.
Cow::Borrowed(shebang.interpreter)
// …otherwise resolve using PATH to respect PATH order
let working_dir = working_directory.unwrap_or_else(|| Path::new("."));
let resolved = which::resolve_executable(shebang.interpreter, working_dir)
.map_err(|error| OutputError::Io(io::Error::new(
io::ErrorKind::NotFound,
format!("{}", error.color_display(Color::never())),
)))?;
Cow::Owned(resolved.to_string_lossy().to_string())
};

let mut cmd = Command::new(command.as_ref());
Expand Down
7 changes: 4 additions & 3 deletions src/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,11 @@ impl<'src, D> Recipe<'src, D> {
continue;
}

let mut cmd = context.module.settings.shell_command(config);
let working_dir = context.working_directory();
let mut cmd = context.module.settings.shell_command(config, &working_dir)?;

if let Some(working_directory) = self.working_directory(context) {
cmd.current_dir(working_directory);
if let Some(recipe_working_directory) = self.working_directory(context) {
cmd.current_dir(recipe_working_directory);
}

cmd.arg(command);
Expand Down
13 changes: 10 additions & 3 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,21 @@ impl<'src> Settings<'src> {
settings
}

pub(crate) fn shell_command(&self, config: &Config) -> Command {
pub(crate) fn shell_command(
&self,
config: &Config,
working_directory: &Path,
) -> RunResult<'static, Command> {
let (command, args) = self.shell(config);

let mut cmd = Command::new(command);
// Resolve executable to absolute path using PATH
let resolved = which::resolve_executable(command, working_directory)?;

let mut cmd = Command::new(resolved);

cmd.args(args);

cmd
Ok(cmd)
}

pub(crate) fn shell<'a>(&'a self, config: &'a Config) -> (&'a str, Vec<&'a str>) {
Expand Down
2 changes: 1 addition & 1 deletion src/subcommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ impl Subcommand {

let result = justfile
.settings
.shell_command(config)
.shell_command(config, &search.working_directory)?
.arg(&chooser)
.current_dir(&search.working_directory)
.stdin(Stdio::piped())
Expand Down
78 changes: 78 additions & 0 deletions src/which.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,83 @@
use super::*;

/// Resolve an executable name to an absolute path using PATH.
///
/// On Windows, also checks PATHEXT extensions (.exe, .bat, .cmd, etc.)
/// This function respects PATH order, unlike Windows' `CreateProcess` which
/// prioritizes System32.
pub(crate) fn resolve_executable(
name: &str,
working_directory: &Path,
) -> RunResult<'static, PathBuf> {
let name = Path::new(name);

// If already absolute, validate it exists and return as-is
if name.is_absolute() {
let candidate = name.lexiclean();
if is_executable::is_executable(&candidate) {
return Ok(candidate);
}
return Err(Error::ExecutableNotFound {
name: name.to_string_lossy().to_string(),
suggestion: None,
});
}

// Check if it's a relative path (contains path separators)
let candidates = if name.components().count() > 1 {
// Relative path - resolve relative to working directory
vec![working_directory.join(name)]
} else {
// Simple command name - search PATH
#[allow(unused_mut)] // mut is needed on Windows for PATHEXT extensions
let mut candidates: Vec<PathBuf> = env::split_paths(
&env::var_os("PATH")
.ok_or_else(|| Error::internal("PATH environment variable not set"))?,
)
.map(|path| path.join(name))
.collect();

// On Windows, also try with PATHEXT extensions
#[cfg(windows)]
{
let pathext = env::var_os("PATHEXT")
.unwrap_or_else(|| OsString::from(".EXE;.COM;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH"));

let extensions: Vec<String> = env::split_paths(&pathext)
.filter_map(|ext| ext.to_str().map(|s| s.to_string()))
.collect();

// For each PATH entry, try each extension
for path in env::split_paths(&env::var_os("PATH").unwrap()) {
for ext in &extensions {
candidates.push(path.join(format!("{}{}", name.to_string_lossy(), ext)));
}
}
}

candidates
};

// Try each candidate
for mut candidate in candidates {
if candidate.is_relative() {
candidate = working_directory.join(candidate);
}

candidate = candidate.lexiclean();

if is_executable::is_executable(&candidate) {
return Ok(candidate);
}
}

// Not found - provide helpful error
Err(Error::ExecutableNotFound {
name: name.to_string_lossy().to_string(),
suggestion: None,
})
}

pub(crate) fn which(context: function::Context, name: &str) -> Result<Option<String>, String> {
let name = Path::new(name);

Expand Down
100 changes: 100 additions & 0 deletions tests/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,103 @@ fn backtick_recipe_shell_not_found_error_message() {
.status(1)
.run();
}

/// Test that shell resolution respects PATH order
/// This verifies that executables are resolved according to PATH order,
/// not system-specific priority (like System32 on Windows)
#[test]
fn shell_respects_path_order() {
let tmp = tempdir();
let path = PathBuf::from(tmp.path());

// Create a custom shell in a directory that comes before system PATH
let custom_dir = path.join("custom");
fs::create_dir_all(&custom_dir).unwrap();

// Create a wrapper script that uses the real sh but identifies itself
// We'll use a simple approach: create a script that calls sh
let script = if cfg!(windows) {
r#"@echo off
echo CUSTOM_SHELL
sh -c %*
"#
} else {
"#!/bin/sh\necho CUSTOM_SHELL\nsh -c \"$@\"\n"
};

Test::with_tempdir(tmp)
.write("custom/sh.exe", script)
.make_executable("custom/sh.exe")
.justfile(
"
set shell := ['sh.exe', '-c']

default:
echo hello
",
)
.env("PATH", custom_dir.to_str().unwrap())
.shell(false)
// Should use our custom sh.exe wrapper
.stdout(if cfg!(windows) {
"CUSTOM_SHELL\r\nhello\r\n"
} else {
"CUSTOM_SHELL\nhello\n"
})
.run();
}

/// Test that executable not found gives a clear error message
#[test]
fn shell_executable_not_found_error() {
Test::new()
.justfile(
"
set shell := ['nonexistent-shell-xyz123', '-c']

default:
echo hello
",
)
.shell(false)
.stderr_regex(r"(?s).*error: Could not find executable `nonexistent-shell-xyz123` in PATH.*")
.status(EXIT_FAILURE)
.run();
}

/// Test that absolute paths work for shell setting
#[test]
fn shell_absolute_path() {
let tmp = tempdir();
let path = PathBuf::from(tmp.path());
let shell_path = path.join("custom-shell.exe");

let script = if cfg!(windows) {
r#"@echo off
echo custom
%*
"#
} else {
"#!/bin/sh\necho custom\nexec sh -c \"$@\"\n"
};

Test::with_tempdir(tmp)
.write("custom-shell.exe", script)
.make_executable("custom-shell.exe")
.justfile(format!(
"
set shell := ['{}', '-c']

default:
echo hello
",
shell_path.display()
))
.shell(false)
.stdout(if cfg!(windows) {
"custom\r\nhello\r\n"
} else {
"custom\nhello\n"
})
.run();
}
Loading