use std::path::{Path, PathBuf};
const EXTRA_ALLOWED: &[u8] = b" ._-+/@:,=";
#[rustfmt::skip]
const SHELL_WORDS: &[&str] = &[
".", ":", "[", "[[", "]]", "alias", "bg", "bind", "break", "builtin",
"caller", "case", "cd", "command", "compgen", "complete", "continue",
"coproc", "declare", "dirs", "disown", "do", "done", "echo", "elif",
"else", "enable", "esac", "eval", "exec", "exit", "export", "false",
"fg", "fi", "for", "function", "getopts", "hash", "history", "if",
"in", "jobs", "kill", "let", "local", "logout", "mapfile", "popd",
"printf", "pushd", "pwd", "read", "readarray", "readonly", "return",
"select", "set", "shift", "shopt", "source", "suspend", "test",
"then", "time", "times", "trap", "true", "type", "typeset", "ulimit",
"umask", "unalias", "unset", "until", "wait", "while", "{", "}",
];
pub(crate) fn simple_command_argv(body: &str) -> Option<(&str, Vec<&str>)> {
let body = body.trim();
if body.is_empty() {
return None;
}
if !body
.bytes()
.all(|b| b.is_ascii_alphanumeric() || EXTRA_ALLOWED.contains(&b))
{
return None;
}
let mut words = body.split_ascii_whitespace();
let program = words.next()?;
if program.contains('=') {
return None;
}
if program.starts_with('-') {
return None;
}
if program.contains('/') {
return None;
}
if SHELL_WORDS.binary_search(&program).is_ok() {
return None;
}
Some((program, words.collect()))
}
enum Candidate {
Runnable,
Miss,
DeferToShell,
}
pub fn resolve_program(program: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
for dir in std::env::split_paths(path) {
if dir.as_os_str().is_empty() || !dir.is_absolute() {
return None;
}
let candidate = dir.join(program);
match classify(&candidate) {
Candidate::Runnable => return Some(candidate),
Candidate::Miss => continue,
Candidate::DeferToShell => return None,
}
}
None
}
fn classify(candidate: &Path) -> Candidate {
let Ok(meta) = std::fs::metadata(candidate) else {
return Candidate::Miss;
};
if !meta.is_file() {
return Candidate::Miss;
}
if !can_execute(candidate, &meta) {
return Candidate::Miss;
}
match launchable(candidate) {
Some(true) => Candidate::Runnable,
Some(false) => Candidate::DeferToShell,
None => Candidate::DeferToShell,
}
}
fn launchable(candidate: &Path) -> Option<bool> {
use std::io::Read;
let mut head = [0u8; 4];
let mut file = std::fs::File::open(candidate).ok()?;
let read = file.read(&mut head).ok()?;
let head = &head[..read];
if head.starts_with(b"#!") {
return Some(true);
}
const NATIVE: &[&[u8]] = &[
b"\x7fELF",
&[0xfe, 0xed, 0xfa, 0xce],
&[0xfe, 0xed, 0xfa, 0xcf],
&[0xce, 0xfa, 0xed, 0xfe],
&[0xcf, 0xfa, 0xed, 0xfe],
&[0xca, 0xfe, 0xba, 0xbe],
&[0xbe, 0xba, 0xfe, 0xca],
b"MZ",
];
Some(NATIVE.iter().any(|m| head.starts_with(m)))
}
#[cfg(unix)]
fn can_execute(candidate: &Path, _meta: &std::fs::Metadata) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(c_path) = std::ffi::CString::new(candidate.as_os_str().as_bytes()) else {
return false;
};
unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
}
#[cfg(not(unix))]
fn can_execute(_candidate: &Path, _meta: &std::fs::Metadata) -> bool {
true
}
fn shell_init_var_set(settings: &crate::ScriptSettings) -> bool {
const SHELL_INIT_VARS: [&str; 2] = ["BASH_ENV", "ENV"];
SHELL_INIT_VARS.iter().any(|var| {
std::env::var_os(var).is_some()
|| settings
.extra_env
.iter()
.any(|(key, _)| key.as_os_str() == std::ffi::OsStr::new(var))
})
}
pub fn direct_argv<'a>(
body: &'a str,
path: &std::ffi::OsStr,
) -> Option<(PathBuf, &'a str, Vec<&'a str>)> {
if cfg!(windows) {
return None;
}
let (program, args) = simple_command_argv(body)?;
let settings = crate::script_settings();
if settings.script_shell.is_some() {
return None;
}
if settings.shell_emulator {
return None;
}
if shell_init_var_set(&settings) {
return None;
}
let resolved = resolve_program(program, path)?;
Some((resolved, program, args))
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(body: &str) -> Option<(String, Vec<String>)> {
simple_command_argv(body)
.map(|(p, a)| (p.to_string(), a.into_iter().map(String::from).collect()))
}
#[test]
fn accepts_plain_commands() {
let cases: &[(&str, &str, &[&str])] = &[
("tsc -p .", "tsc", &["-p", "."]),
("vitest run", "vitest", &["run"]),
("next dev", "next", &["dev"]),
("node hello.js", "node", &["hello.js"]),
("eslint . --fix", "eslint", &[".", "--fix"]),
(
"esbuild src/x.ts --target=es2020",
"esbuild",
&["--target=es2020"],
),
("husky", "husky", &[]),
(" tsc -p . ", "tsc", &["-p", "."]),
];
for (body, program, _) in cases {
let (got, _) = argv(body).unwrap_or_else(|| panic!("{body} should be direct"));
assert_eq!(&got, program, "{body}");
}
assert_eq!(
argv("esbuild src/x.ts --target=es2020"),
Some((
"esbuild".to_string(),
vec!["src/x.ts".to_string(), "--target=es2020".to_string()]
))
);
assert_eq!(argv("husky"), Some(("husky".to_string(), vec![])));
}
#[test]
fn bails_on_anything_a_shell_would_interpret() {
let cases = [
("foo && bar", "and-chain"),
("foo; bar", "semicolon"),
("foo | bar", "pipe"),
("foo &", "background"),
("foo > out", "redirect out"),
("foo < in", "redirect in"),
("(foo)", "subshell"),
("a $V", "expansion"),
("a ${V}", "braced expansion"),
("a `b`", "command substitution"),
("a ~/x", "tilde"),
("a *.ts", "glob star"),
("a x?.ts", "glob question"),
("a [ab].ts", "glob class"),
("a {b,c}", "brace expansion"),
("a 'q'", "single quotes"),
("a \"q\"", "double quotes"),
("a\\b", "backslash"),
("FOO=bar node x.js", "assignment prefix"),
("# c", "comment"),
("node -e \"\"", "quoted -e"),
("foo\nbar", "newline"),
("foo\tbar", "tab"),
("café", "non-ascii"),
("-flag x", "leading flag"),
("./x.js", "relative path program"),
("node_modules/.bin/x", "path program"),
("", "empty"),
(" ", "blank"),
("a %V%", "percent"),
("a ^b", "caret"),
("a !b", "bang"),
];
for (body, why) in cases {
assert!(argv(body).is_none(), "{why}: {body:?} must use the shell");
}
}
#[test]
fn bails_on_shell_builtins_and_keywords() {
for word in [
"exit", ":", ".", "cd", "export", "unset", "set", "shift", "source", "eval", "exec",
"read", "local", "readonly", "trap", "wait", "umask", "ulimit", "times", "hash",
"getopts", "alias", "break", "continue", "return", "command", "type",
] {
assert!(argv(word).is_none(), "builtin without a binary: {word}");
assert!(argv(&format!("{word} 7")).is_none(), "with args: {word}");
}
for word in [
"echo", "true", "false", "test", "[", "printf", "pwd", "kill",
] {
assert!(
argv(word).is_none(),
"builtin with divergent binary: {word}"
);
}
for word in [
"if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case",
"esac", "in", "function", "select", "time", "[[", "{", "}",
] {
assert!(argv(word).is_none(), "keyword: {word}");
}
}
#[test]
fn exit_seven_still_reaches_the_shell() {
assert!(argv("exit 7").is_none());
}
#[test]
fn builtin_list_is_sorted_and_deduped() {
let mut sorted = SHELL_WORDS.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
SHELL_WORDS,
&sorted[..],
"SHELL_WORDS must stay sorted and deduped for binary_search"
);
}
fn scratch(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let dir = std::env::temp_dir().join(format!("aube-direct-{tag}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn exe(path: &Path) {
std::fs::write(path, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
fn join(dirs: &[&Path]) -> std::ffi::OsString {
std::env::join_paths(dirs.iter().map(|d| d.to_path_buf())).unwrap()
}
#[test]
fn resolve_program_finds_an_executable() {
let dir = scratch("hit");
exe(&dir.join("tool"));
assert_eq!(
resolve_program("tool", &join(&[&dir])),
Some(dir.join("tool"))
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn resolve_program_skips_non_executable_files() {
let dir = scratch("noexec");
std::fs::write(dir.join("tool"), "not executable").unwrap();
assert_eq!(resolve_program("tool", &join(&[&dir])), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_program_skips_directories() {
let dir = scratch("isdir");
std::fs::create_dir(dir.join("tool")).unwrap();
assert_eq!(resolve_program("tool", &join(&[&dir])), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_program_takes_the_first_hit_in_path_order() {
let first = scratch("first");
let second = scratch("second");
exe(&first.join("tool"));
exe(&second.join("tool"));
assert_eq!(
resolve_program("tool", &join(&[&first, &second])),
Some(first.join("tool"))
);
std::fs::remove_dir_all(&first).ok();
std::fs::remove_dir_all(&second).ok();
}
#[test]
fn resolve_program_keeps_looking_past_a_dir_without_the_program() {
let miss = scratch("miss");
let hit = scratch("late-hit");
exe(&hit.join("tool"));
assert_eq!(
resolve_program("tool", &join(&[&miss, &hit])),
Some(hit.join("tool"))
);
std::fs::remove_dir_all(&miss).ok();
std::fs::remove_dir_all(&hit).ok();
}
#[test]
fn resolve_program_gives_up_on_a_relative_path_entry() {
let dir = scratch("relative");
exe(&dir.join("tool"));
assert_eq!(
resolve_program("tool", &join(&[Path::new("relative"), &dir])),
None
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn resolve_program_defers_an_executable_without_a_shebang() {
let dir = scratch("noexec-hdr");
let tool = dir.join("tool");
std::fs::write(&tool, "echo hi\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(resolve_program("tool", &join(&[&dir])), None);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn resolve_program_accepts_a_native_binary() {
let dir = scratch("elf");
let tool = dir.join("tool");
std::fs::write(&tool, b"\x7fELF\x02\x01\x01").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(
resolve_program("tool", &join(&[&dir])),
Some(dir.join("tool"))
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn resolve_program_keeps_searching_past_an_unexecutable_hit() {
let shadow = scratch("shadow");
let real = scratch("real");
let blocked = shadow.join("tool");
std::fs::write(&blocked, "#!/bin/sh\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
exe(&real.join("tool"));
let got = resolve_program("tool", &join(&[&shadow, &real]));
if unsafe { libc::geteuid() } == 0 {
assert!(got.is_some());
} else {
assert_eq!(got, Some(real.join("tool")));
}
std::fs::remove_dir_all(&shadow).ok();
std::fs::remove_dir_all(&real).ok();
}
#[tokio::test]
async fn direct_argv_declines_when_extra_env_sets_bash_env() {
let dir = scratch("extra-env");
exe(&dir.join("tool"));
let settings = crate::ScriptSettings {
extra_env: vec![(
std::ffi::OsString::from("BASH_ENV"),
std::ffi::OsString::from("/tmp/init.sh"),
)],
..Default::default()
};
assert!(!plans_under(settings, &dir).await);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn resolve_program_ignores_a_dangling_symlink() {
let dir = scratch("dangling");
std::os::unix::fs::symlink(dir.join("nope"), dir.join("tool")).unwrap();
assert_eq!(resolve_program("tool", &join(&[&dir])), None);
std::fs::remove_dir_all(&dir).ok();
}
async fn plans_under(settings: crate::ScriptSettings, dir: &Path) -> bool {
let path = join(&[dir]);
crate::scope(async move {
crate::set_script_settings(settings);
direct_argv("tool --flag x", &path).is_some()
})
.await
}
#[tokio::test]
async fn direct_argv_declines_when_a_custom_script_shell_is_set() {
let dir = scratch("script-shell");
exe(&dir.join("tool"));
let settings = crate::ScriptSettings {
script_shell: Some(PathBuf::from("/bin/bash")),
..Default::default()
};
assert!(!plans_under(settings, &dir).await);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn direct_argv_declines_under_the_shell_emulator() {
let dir = scratch("shell-emulator");
exe(&dir.join("tool"));
let settings = crate::ScriptSettings {
shell_emulator: true,
..Default::default()
};
assert!(!plans_under(settings, &dir).await);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[tokio::test]
async fn direct_argv_plans_a_bare_command_with_default_settings() {
if std::env::var_os("BASH_ENV").is_some() || std::env::var_os("ENV").is_some() {
return;
}
let dir = scratch("plan");
exe(&dir.join("tool"));
let path = join(&[&dir]);
let expected = dir.join("tool");
crate::scope(async move {
crate::set_script_settings(crate::ScriptSettings::default());
let (resolved, word, args) = direct_argv("tool --flag x", &path).unwrap();
assert_eq!(resolved, expected);
assert_eq!(word, "tool");
assert_eq!(args, vec!["--flag", "x"]);
})
.await;
std::fs::remove_dir_all(&dir).ok();
}
}