Skip to main content

aube_scripts/
direct.rs

1//! Direct-exec fast path for `aube run`.
2//!
3//! Every package script normally goes through `sh -c "<body>"`. `/bin/sh`
4//! does not exec in place — dash stays resident as the script's parent —
5//! so a shell costs a whole extra process per invocation. For a body that
6//! is one plain command (`tsc -p .`, `vitest run`, `node build.js`) the
7//! shell contributes nothing but that process.
8//!
9//! This module decides when a body can skip the shell. It is deliberately
10//! a strict allowlist rather than a tokenizer: a tokenizer splits words
11//! but says nothing about shell *semantics*, so it cannot tell us whether
12//! the shell was load-bearing. Anything we do not recognize with
13//! certainty falls back to `sh -c`, which is the pre-existing behavior.
14//! Every bail is a correctness win traded for a process we keep paying.
15
16use std::path::{Path, PathBuf};
17
18/// Bytes allowed to appear anywhere in a directly-exec'd command line.
19///
20/// ASCII alphanumerics plus these. Everything else — `;` `&` `|` `(` `)`
21/// `<` `>` `$` backtick `"` `'` `\` `*` `?` `[` `]` `{` `}` `~` `#` `!`
22/// `^` `%`, newlines, tabs, other control bytes, and all non-ASCII —
23/// means either a shell operator, an expansion, quoting, or something we
24/// have not thought about, and sends the body to `sh`.
25const EXTRA_ALLOWED: &[u8] = b" ._-+/@:,=";
26
27/// Words that must never be exec'd directly, in two hazard classes.
28///
29/// The first is builtins with no binary at all: `exit 7` as a script body
30/// works today and must keep working. The second is builtins that *do*
31/// have a binary whose behavior differs from the shell's — `echo -e`,
32/// `printf`, and `test` all diverge between dash, bash, and coreutils, so
33/// exec'ing the binary would silently change what a script does.
34///
35/// Sorted for `binary_search`; `builtin_list_is_sorted_and_deduped`
36/// enforces that. Missing an entry is not a correctness hole on its own,
37/// because an unresolvable word falls back to the shell anyway — this
38/// list is what makes the common builtins *guaranteed* rather than
39/// accidentally correct.
40#[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
54/// Split a script body into `(program, args)` when it is a single plain
55/// command that a shell would add nothing to. `None` means "use `sh`".
56///
57/// Recognizes only bodies built from [`EXTRA_ALLOWED`] bytes whose first
58/// word is a bare program name. See the module docs for why this is a
59/// scanner and not a parser.
60pub(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    // Space is the only separator that survived the scan, so the split is
73    // unambiguous — no quoting or escaping can be in play.
74    let mut words = body.split_ascii_whitespace();
75    let program = words.next()?;
76
77    // A leading `FOO=bar` is a shell assignment prefix, not a program. `=`
78    // is still fine in later words (`--target=es2020`).
79    if program.contains('=') {
80        return None;
81    }
82    // Looks like a flag, so we have misread the body somehow.
83    if program.starts_with('-') {
84        return None;
85    }
86    // A path-shaped program would make us reason about how std resolves a
87    // relative program against `current_dir`. Real scripts invoke bare
88    // names (`tsc`, `vitest`, `node`), so the case is not worth owning.
89    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
99/// What a PATH candidate is, from the point of view of "may we exec it
100/// ourselves without changing what the script does".
101enum Candidate {
102    /// Executable by us, and the kernel can launch it directly.
103    Runnable,
104    /// Not a usable hit. Keep walking PATH, as a shell would.
105    Miss,
106    /// Exists and we could run it, but `sh` would do something else with
107    /// it — so hand the whole body back to `sh`.
108    DeferToShell,
109}
110
111/// Find `program` on `path`, mirroring how the shell would resolve it.
112///
113/// This is a correctness requirement, not an optimization: on Unix
114/// `Command::new("tsc")` resolves through `execvp`, which searches the
115/// *parent's* environ and ignores the `PATH` we hand the child — so
116/// without resolving here ourselves, a `node_modules/.bin` program would
117/// not be found at all.
118///
119/// Costs a `stat` and a 4-byte read per candidate until a hit (typically
120/// one for a project-local bin, three for `node`), which is noise next to
121/// the fork and shell startup it replaces. Deliberately uncached: a cache
122/// would have to be invalidated on every install, and there is nothing to
123/// win.
124pub fn resolve_program(program: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
125    for dir in std::env::split_paths(path) {
126        // POSIX reads an empty entry as the cwd. Rather than reason about
127        // a cwd-relative match, treat the whole search as inconclusive
128        // and let the shell handle the body.
129        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    // `metadata` follows symlinks, so a `.bin/tsc -> ../pkg/cli.js` link
144    // resolves to the real file.
145    let Ok(meta) = std::fs::metadata(candidate) else {
146        return Candidate::Miss;
147    };
148    if !meta.is_file() {
149        return Candidate::Miss;
150    }
151    // Mode bits alone answer "is this marked executable", not "may *we*
152    // execute it" — a file can carry `--x` for an owner we are not. A
153    // shell keeps walking PATH in that case, so a hit we could not launch
154    // must not end the search, or a later runnable entry gets shadowed by
155    // an EACCES we would report as a spawn failure.
156    if !can_execute(candidate, &meta) {
157        return Candidate::Miss;
158    }
159    // `sh -c tool` runs an executable *without* a shebang or a native
160    // header as a shell script; exec'ing it ourselves fails with
161    // ENOEXEC. Only launch what the kernel can launch on its own and let
162    // `sh` keep the rest, including its own interpretation of them.
163    match launchable(candidate) {
164        Some(true) => Candidate::Runnable,
165        Some(false) => Candidate::DeferToShell,
166        // Unreadable but executable (`--x`) is legal and the kernel may
167        // well run it; we just cannot tell what it is, so we do not guess.
168        None => Candidate::DeferToShell,
169    }
170}
171
172/// Whether the file starts with `#!` or a native executable header —
173/// i.e. whether `execve` alone can launch it.
174fn 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    // ELF, the Mach-O 32/64-bit and fat variants, and PE. Matching
185    // aube-linker's magic list without taking a dependency on it for four
186    // byte comparisons.
187    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    // `access(X_OK)` is what a shell's PATH search asks, so ask the same
205    // question rather than re-deriving it from mode bits and our uid.
206    let Ok(c_path) = std::ffi::CString::new(candidate.as_os_str().as_bytes()) else {
207        return false;
208    };
209    // SAFETY: `c_path` is a valid NUL-terminated C string that outlives
210    // the call, and `access` only reads it.
211    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    // Only reachable from tests; the fast path itself is Unix-only.
217    true
218}
219
220/// Whether `BASH_ENV` or `ENV` reaches the child, from either our own
221/// environment or an embedder's `extra_env` contribution.
222fn 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
233/// Plan a direct exec of `body` against `path`, or `None` to use `sh`.
234///
235/// Returns `(resolved_program, program_as_written, args)`. The second
236/// element becomes `argv[0]`, matching what the shell would have passed.
237pub fn direct_argv<'a>(
238    body: &'a str,
239    path: &std::ffi::OsStr,
240) -> Option<(PathBuf, &'a str, Vec<&'a str>)> {
241    // Windows would need PATHEXT plus the `.cmd`/`.ps1`/bare-sh shim
242    // triple, and `CreateProcess` cannot run a `.cmd` at all — Windows
243    // re-enters `cmd.exe` for batch files, so the process we skipped
244    // comes right back. `cfg!` rather than `#[cfg]` so this module's
245    // tests still compile and run on the Windows CI job.
246    if cfg!(windows) {
247        return None;
248    }
249
250    // Scan first. It is a pure pass over the body, where every check below
251    // reads settings (cloning the snapshot) or the environment — so a body
252    // that was always going to need a shell pays nothing for asking.
253    let (program, args) = simple_command_argv(body)?;
254
255    let settings = crate::script_settings();
256    // The user pointed scripts at a specific shell; run them in it.
257    if settings.script_shell.is_some() {
258        return None;
259    }
260    // Signals intent about shell semantics even though aube does not
261    // currently emulate one.
262    if settings.shell_emulator {
263        return None;
264    }
265    // Where `/bin/sh` is bash (macOS), bash sources `$BASH_ENV` for
266    // non-interactive shells, so a script body can legitimately depend on
267    // functions or PATH edits from that file. Same for `$ENV` under a
268    // POSIX sh. If either is set, the shell is load-bearing.
269    //
270    // Check the child's effective environment, not just ours: an embedder
271    // can contribute either var through `extra_env`, which
272    // `apply_script_settings_env` stamps onto the command we are about to
273    // build.
274    if shell_init_var_set(&settings) {
275        return None;
276    }
277
278    // Resolution failure is not an error — falling back to `sh` preserves
279    // the shell's exit 127 and its exact `sh: 1: foo: not found` stderr,
280    // and 126 for a hit that is not executable.
281    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        // Spot-check full argv, including the `/`-containing later word
315        // that the first-word `/` rule must not reject.
316        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        // Split out so a failure names the class. The first group has no
370        // binary at all; the second has one that behaves differently.
371        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        // Pins the `"boom": "exit 7"` e2e fixture: `exit` has no binary,
398        // so exec'ing it would turn a working script into ENOENT.
399        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    /// Unique scratch dir. `tempfile` is deliberately not a dep of this
415    /// crate (see `aborting_script_kills_grandchildren`), so follow the
416    /// same `temp_dir` + nanos convention.
417    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        // `sh -c tool` runs this as a shell script; exec'ing it would fail
510        // with ENOEXEC. Bail so the shell keeps interpreting it.
511        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        // Marked executable for a user we are not: a shell walks on to the
539        // next PATH entry, so a later runnable entry must not be shadowed.
540        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        // `--x------` with our uid stripped of the bit is not expressible
546        // without changing owner, so use 0o100 and skip when running as
547        // root (which bypasses the check entirely).
548        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            // root ignores permission bits; the first hit legitimately wins.
553            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        // An embedder can inject a shell init file through extra_env, and
573        // `apply_script_settings_env` would stamp it on the child — so the
574        // shell is load-bearing even though our own env is clean.
575        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    /// `direct_argv` reads the task-local settings snapshot, so drive it
589    /// through `scope` the way `scoped_settings_tests` does rather than
590    /// mutating the process-global fallback.
591    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        // `BASH_ENV` / `ENV` in the ambient environment legitimately veto
628        // the fast path, so only assert the plan when this process is
629        // clean. Reading them is why this is not a table with the two
630        // decline cases above.
631        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}