1use std::path::{Path, PathBuf};
17
18const EXTRA_ALLOWED: &[u8] = b" ._-+/@:,=";
26
27#[rustfmt::skip]
41const SHELL_WORDS: &[&str] = &[
42 ".", ":", "[", "[[", "]]", "alias", "bg", "bind", "break", "builtin",
43 "caller", "case", "cd", "command", "compgen", "complete", "continue",
44 "coproc", "declare", "dirs", "disown", "do", "done", "echo", "elif",
45 "else", "enable", "esac", "eval", "exec", "exit", "export", "false",
46 "fg", "fi", "for", "function", "getopts", "hash", "history", "if",
47 "in", "jobs", "kill", "let", "local", "logout", "mapfile", "popd",
48 "printf", "pushd", "pwd", "read", "readarray", "readonly", "return",
49 "select", "set", "shift", "shopt", "source", "suspend", "test",
50 "then", "time", "times", "trap", "true", "type", "typeset", "ulimit",
51 "umask", "unalias", "unset", "until", "wait", "while", "{", "}",
52];
53
54pub(crate) fn simple_command_argv(body: &str) -> Option<(&str, Vec<&str>)> {
61 let body = body.trim();
62 if body.is_empty() {
63 return None;
64 }
65 if !body
66 .bytes()
67 .all(|b| b.is_ascii_alphanumeric() || EXTRA_ALLOWED.contains(&b))
68 {
69 return None;
70 }
71
72 let mut words = body.split_ascii_whitespace();
75 let program = words.next()?;
76
77 if program.contains('=') {
80 return None;
81 }
82 if program.starts_with('-') {
84 return None;
85 }
86 if program.contains('/') {
90 return None;
91 }
92 if SHELL_WORDS.binary_search(&program).is_ok() {
93 return None;
94 }
95
96 Some((program, words.collect()))
97}
98
99enum Candidate {
102 Runnable,
104 Miss,
106 DeferToShell,
109}
110
111pub fn resolve_program(program: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
125 for dir in std::env::split_paths(path) {
126 if dir.as_os_str().is_empty() || !dir.is_absolute() {
130 return None;
131 }
132 let candidate = dir.join(program);
133 match classify(&candidate) {
134 Candidate::Runnable => return Some(candidate),
135 Candidate::Miss => continue,
136 Candidate::DeferToShell => return None,
137 }
138 }
139 None
140}
141
142fn classify(candidate: &Path) -> Candidate {
143 let Ok(meta) = std::fs::metadata(candidate) else {
146 return Candidate::Miss;
147 };
148 if !meta.is_file() {
149 return Candidate::Miss;
150 }
151 if !can_execute(candidate, &meta) {
157 return Candidate::Miss;
158 }
159 match launchable(candidate) {
164 Some(true) => Candidate::Runnable,
165 Some(false) => Candidate::DeferToShell,
166 None => Candidate::DeferToShell,
169 }
170}
171
172fn launchable(candidate: &Path) -> Option<bool> {
175 use std::io::Read;
176
177 let mut head = [0u8; 4];
178 let mut file = std::fs::File::open(candidate).ok()?;
179 let read = file.read(&mut head).ok()?;
180 let head = &head[..read];
181 if head.starts_with(b"#!") {
182 return Some(true);
183 }
184 const NATIVE: &[&[u8]] = &[
188 b"\x7fELF",
189 &[0xfe, 0xed, 0xfa, 0xce],
190 &[0xfe, 0xed, 0xfa, 0xcf],
191 &[0xce, 0xfa, 0xed, 0xfe],
192 &[0xcf, 0xfa, 0xed, 0xfe],
193 &[0xca, 0xfe, 0xba, 0xbe],
194 &[0xbe, 0xba, 0xfe, 0xca],
195 b"MZ",
196 ];
197 Some(NATIVE.iter().any(|m| head.starts_with(m)))
198}
199
200#[cfg(unix)]
201fn can_execute(candidate: &Path, _meta: &std::fs::Metadata) -> bool {
202 use std::os::unix::ffi::OsStrExt;
203
204 let Ok(c_path) = std::ffi::CString::new(candidate.as_os_str().as_bytes()) else {
207 return false;
208 };
209 unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
212}
213
214#[cfg(not(unix))]
215fn can_execute(_candidate: &Path, _meta: &std::fs::Metadata) -> bool {
216 true
218}
219
220fn shell_init_var_set(settings: &crate::ScriptSettings) -> bool {
223 const SHELL_INIT_VARS: [&str; 2] = ["BASH_ENV", "ENV"];
224 SHELL_INIT_VARS.iter().any(|var| {
225 std::env::var_os(var).is_some()
226 || settings
227 .extra_env
228 .iter()
229 .any(|(key, _)| key.as_os_str() == std::ffi::OsStr::new(var))
230 })
231}
232
233pub fn direct_argv<'a>(
238 body: &'a str,
239 path: &std::ffi::OsStr,
240) -> Option<(PathBuf, &'a str, Vec<&'a str>)> {
241 if cfg!(windows) {
247 return None;
248 }
249
250 let (program, args) = simple_command_argv(body)?;
254
255 let settings = crate::script_settings();
256 if settings.script_shell.is_some() {
258 return None;
259 }
260 if settings.shell_emulator {
263 return None;
264 }
265 if shell_init_var_set(&settings) {
275 return None;
276 }
277
278 let resolved = resolve_program(program, path)?;
282 Some((resolved, program, args))
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn argv(body: &str) -> Option<(String, Vec<String>)> {
290 simple_command_argv(body)
291 .map(|(p, a)| (p.to_string(), a.into_iter().map(String::from).collect()))
292 }
293
294 #[test]
295 fn accepts_plain_commands() {
296 let cases: &[(&str, &str, &[&str])] = &[
297 ("tsc -p .", "tsc", &["-p", "."]),
298 ("vitest run", "vitest", &["run"]),
299 ("next dev", "next", &["dev"]),
300 ("node hello.js", "node", &["hello.js"]),
301 ("eslint . --fix", "eslint", &[".", "--fix"]),
302 (
303 "esbuild src/x.ts --target=es2020",
304 "esbuild",
305 &["--target=es2020"],
306 ),
307 ("husky", "husky", &[]),
308 (" tsc -p . ", "tsc", &["-p", "."]),
309 ];
310 for (body, program, _) in cases {
311 let (got, _) = argv(body).unwrap_or_else(|| panic!("{body} should be direct"));
312 assert_eq!(&got, program, "{body}");
313 }
314 assert_eq!(
317 argv("esbuild src/x.ts --target=es2020"),
318 Some((
319 "esbuild".to_string(),
320 vec!["src/x.ts".to_string(), "--target=es2020".to_string()]
321 ))
322 );
323 assert_eq!(argv("husky"), Some(("husky".to_string(), vec![])));
324 }
325
326 #[test]
327 fn bails_on_anything_a_shell_would_interpret() {
328 let cases = [
329 ("foo && bar", "and-chain"),
330 ("foo; bar", "semicolon"),
331 ("foo | bar", "pipe"),
332 ("foo &", "background"),
333 ("foo > out", "redirect out"),
334 ("foo < in", "redirect in"),
335 ("(foo)", "subshell"),
336 ("a $V", "expansion"),
337 ("a ${V}", "braced expansion"),
338 ("a `b`", "command substitution"),
339 ("a ~/x", "tilde"),
340 ("a *.ts", "glob star"),
341 ("a x?.ts", "glob question"),
342 ("a [ab].ts", "glob class"),
343 ("a {b,c}", "brace expansion"),
344 ("a 'q'", "single quotes"),
345 ("a \"q\"", "double quotes"),
346 ("a\\b", "backslash"),
347 ("FOO=bar node x.js", "assignment prefix"),
348 ("# c", "comment"),
349 ("node -e \"\"", "quoted -e"),
350 ("foo\nbar", "newline"),
351 ("foo\tbar", "tab"),
352 ("café", "non-ascii"),
353 ("-flag x", "leading flag"),
354 ("./x.js", "relative path program"),
355 ("node_modules/.bin/x", "path program"),
356 ("", "empty"),
357 (" ", "blank"),
358 ("a %V%", "percent"),
359 ("a ^b", "caret"),
360 ("a !b", "bang"),
361 ];
362 for (body, why) in cases {
363 assert!(argv(body).is_none(), "{why}: {body:?} must use the shell");
364 }
365 }
366
367 #[test]
368 fn bails_on_shell_builtins_and_keywords() {
369 for word in [
372 "exit", ":", ".", "cd", "export", "unset", "set", "shift", "source", "eval", "exec",
373 "read", "local", "readonly", "trap", "wait", "umask", "ulimit", "times", "hash",
374 "getopts", "alias", "break", "continue", "return", "command", "type",
375 ] {
376 assert!(argv(word).is_none(), "builtin without a binary: {word}");
377 assert!(argv(&format!("{word} 7")).is_none(), "with args: {word}");
378 }
379 for word in [
380 "echo", "true", "false", "test", "[", "printf", "pwd", "kill",
381 ] {
382 assert!(
383 argv(word).is_none(),
384 "builtin with divergent binary: {word}"
385 );
386 }
387 for word in [
388 "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case",
389 "esac", "in", "function", "select", "time", "[[", "{", "}",
390 ] {
391 assert!(argv(word).is_none(), "keyword: {word}");
392 }
393 }
394
395 #[test]
396 fn exit_seven_still_reaches_the_shell() {
397 assert!(argv("exit 7").is_none());
400 }
401
402 #[test]
403 fn builtin_list_is_sorted_and_deduped() {
404 let mut sorted = SHELL_WORDS.to_vec();
405 sorted.sort_unstable();
406 sorted.dedup();
407 assert_eq!(
408 SHELL_WORDS,
409 &sorted[..],
410 "SHELL_WORDS must stay sorted and deduped for binary_search"
411 );
412 }
413
414 fn scratch(tag: &str) -> PathBuf {
418 let nanos = std::time::SystemTime::now()
419 .duration_since(std::time::UNIX_EPOCH)
420 .unwrap_or_default()
421 .as_nanos();
422 let dir = std::env::temp_dir().join(format!("aube-direct-{tag}-{nanos}"));
423 std::fs::create_dir_all(&dir).unwrap();
424 dir
425 }
426
427 fn exe(path: &Path) {
428 std::fs::write(path, "#!/bin/sh\n").unwrap();
429 #[cfg(unix)]
430 {
431 use std::os::unix::fs::PermissionsExt;
432 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
433 }
434 }
435
436 fn join(dirs: &[&Path]) -> std::ffi::OsString {
437 std::env::join_paths(dirs.iter().map(|d| d.to_path_buf())).unwrap()
438 }
439
440 #[test]
441 fn resolve_program_finds_an_executable() {
442 let dir = scratch("hit");
443 exe(&dir.join("tool"));
444 assert_eq!(
445 resolve_program("tool", &join(&[&dir])),
446 Some(dir.join("tool"))
447 );
448 std::fs::remove_dir_all(&dir).ok();
449 }
450
451 #[cfg(unix)]
452 #[test]
453 fn resolve_program_skips_non_executable_files() {
454 let dir = scratch("noexec");
455 std::fs::write(dir.join("tool"), "not executable").unwrap();
456 assert_eq!(resolve_program("tool", &join(&[&dir])), None);
457 std::fs::remove_dir_all(&dir).ok();
458 }
459
460 #[test]
461 fn resolve_program_skips_directories() {
462 let dir = scratch("isdir");
463 std::fs::create_dir(dir.join("tool")).unwrap();
464 assert_eq!(resolve_program("tool", &join(&[&dir])), None);
465 std::fs::remove_dir_all(&dir).ok();
466 }
467
468 #[test]
469 fn resolve_program_takes_the_first_hit_in_path_order() {
470 let first = scratch("first");
471 let second = scratch("second");
472 exe(&first.join("tool"));
473 exe(&second.join("tool"));
474 assert_eq!(
475 resolve_program("tool", &join(&[&first, &second])),
476 Some(first.join("tool"))
477 );
478 std::fs::remove_dir_all(&first).ok();
479 std::fs::remove_dir_all(&second).ok();
480 }
481
482 #[test]
483 fn resolve_program_keeps_looking_past_a_dir_without_the_program() {
484 let miss = scratch("miss");
485 let hit = scratch("late-hit");
486 exe(&hit.join("tool"));
487 assert_eq!(
488 resolve_program("tool", &join(&[&miss, &hit])),
489 Some(hit.join("tool"))
490 );
491 std::fs::remove_dir_all(&miss).ok();
492 std::fs::remove_dir_all(&hit).ok();
493 }
494
495 #[test]
496 fn resolve_program_gives_up_on_a_relative_path_entry() {
497 let dir = scratch("relative");
498 exe(&dir.join("tool"));
499 assert_eq!(
500 resolve_program("tool", &join(&[Path::new("relative"), &dir])),
501 None
502 );
503 std::fs::remove_dir_all(&dir).ok();
504 }
505
506 #[cfg(unix)]
507 #[test]
508 fn resolve_program_defers_an_executable_without_a_shebang() {
509 let dir = scratch("noexec-hdr");
512 let tool = dir.join("tool");
513 std::fs::write(&tool, "echo hi\n").unwrap();
514 use std::os::unix::fs::PermissionsExt;
515 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
516 assert_eq!(resolve_program("tool", &join(&[&dir])), None);
517 std::fs::remove_dir_all(&dir).ok();
518 }
519
520 #[cfg(unix)]
521 #[test]
522 fn resolve_program_accepts_a_native_binary() {
523 let dir = scratch("elf");
524 let tool = dir.join("tool");
525 std::fs::write(&tool, b"\x7fELF\x02\x01\x01").unwrap();
526 use std::os::unix::fs::PermissionsExt;
527 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
528 assert_eq!(
529 resolve_program("tool", &join(&[&dir])),
530 Some(dir.join("tool"))
531 );
532 std::fs::remove_dir_all(&dir).ok();
533 }
534
535 #[cfg(unix)]
536 #[test]
537 fn resolve_program_keeps_searching_past_an_unexecutable_hit() {
538 let shadow = scratch("shadow");
541 let real = scratch("real");
542 let blocked = shadow.join("tool");
543 std::fs::write(&blocked, "#!/bin/sh\n").unwrap();
544 use std::os::unix::fs::PermissionsExt;
545 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
549 exe(&real.join("tool"));
550 let got = resolve_program("tool", &join(&[&shadow, &real]));
551 if unsafe { libc::geteuid() } == 0 {
552 assert!(got.is_some());
554 } else {
555 assert_eq!(got, Some(real.join("tool")));
556 }
557 std::fs::remove_dir_all(&shadow).ok();
558 std::fs::remove_dir_all(&real).ok();
559 }
560
561 #[tokio::test]
562 async fn direct_argv_declines_when_extra_env_sets_bash_env() {
563 let dir = scratch("extra-env");
564 exe(&dir.join("tool"));
565 let settings = crate::ScriptSettings {
566 extra_env: vec![(
567 std::ffi::OsString::from("BASH_ENV"),
568 std::ffi::OsString::from("/tmp/init.sh"),
569 )],
570 ..Default::default()
571 };
572 assert!(!plans_under(settings, &dir).await);
576 std::fs::remove_dir_all(&dir).ok();
577 }
578
579 #[cfg(unix)]
580 #[test]
581 fn resolve_program_ignores_a_dangling_symlink() {
582 let dir = scratch("dangling");
583 std::os::unix::fs::symlink(dir.join("nope"), dir.join("tool")).unwrap();
584 assert_eq!(resolve_program("tool", &join(&[&dir])), None);
585 std::fs::remove_dir_all(&dir).ok();
586 }
587
588 async fn plans_under(settings: crate::ScriptSettings, dir: &Path) -> bool {
592 let path = join(&[dir]);
593 crate::scope(async move {
594 crate::set_script_settings(settings);
595 direct_argv("tool --flag x", &path).is_some()
596 })
597 .await
598 }
599
600 #[tokio::test]
601 async fn direct_argv_declines_when_a_custom_script_shell_is_set() {
602 let dir = scratch("script-shell");
603 exe(&dir.join("tool"));
604 let settings = crate::ScriptSettings {
605 script_shell: Some(PathBuf::from("/bin/bash")),
606 ..Default::default()
607 };
608 assert!(!plans_under(settings, &dir).await);
609 std::fs::remove_dir_all(&dir).ok();
610 }
611
612 #[tokio::test]
613 async fn direct_argv_declines_under_the_shell_emulator() {
614 let dir = scratch("shell-emulator");
615 exe(&dir.join("tool"));
616 let settings = crate::ScriptSettings {
617 shell_emulator: true,
618 ..Default::default()
619 };
620 assert!(!plans_under(settings, &dir).await);
621 std::fs::remove_dir_all(&dir).ok();
622 }
623
624 #[cfg(unix)]
625 #[tokio::test]
626 async fn direct_argv_plans_a_bare_command_with_default_settings() {
627 if std::env::var_os("BASH_ENV").is_some() || std::env::var_os("ENV").is_some() {
632 return;
633 }
634 let dir = scratch("plan");
635 exe(&dir.join("tool"));
636 let path = join(&[&dir]);
637 let expected = dir.join("tool");
638 crate::scope(async move {
639 crate::set_script_settings(crate::ScriptSettings::default());
640 let (resolved, word, args) = direct_argv("tool --flag x", &path).unwrap();
641 assert_eq!(resolved, expected);
642 assert_eq!(word, "tool");
643 assert_eq!(args, vec!["--flag", "x"]);
644 })
645 .await;
646 std::fs::remove_dir_all(&dir).ok();
647 }
648}