aube-scripts 2.2.6

Lifecycle script runner for Aube
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
//! Direct-exec fast path for `aube run`.
//!
//! Every package script normally goes through `sh -c "<body>"`. `/bin/sh`
//! does not exec in place — dash stays resident as the script's parent —
//! so a shell costs a whole extra process per invocation. For a body that
//! is one plain command (`tsc -p .`, `vitest run`, `node build.js`) the
//! shell contributes nothing but that process.
//!
//! This module decides when a body can skip the shell. It is deliberately
//! a strict allowlist rather than a tokenizer: a tokenizer splits words
//! but says nothing about shell *semantics*, so it cannot tell us whether
//! the shell was load-bearing. Anything we do not recognize with
//! certainty falls back to `sh -c`, which is the pre-existing behavior.
//! Every bail is a correctness win traded for a process we keep paying.

use std::path::{Path, PathBuf};

/// Bytes allowed to appear anywhere in a directly-exec'd command line.
///
/// ASCII alphanumerics plus these. Everything else — `;` `&` `|` `(` `)`
/// `<` `>` `$` backtick `"` `'` `\` `*` `?` `[` `]` `{` `}` `~` `#` `!`
/// `^` `%`, newlines, tabs, other control bytes, and all non-ASCII —
/// means either a shell operator, an expansion, quoting, or something we
/// have not thought about, and sends the body to `sh`.
const EXTRA_ALLOWED: &[u8] = b" ._-+/@:,=";

/// Words that must never be exec'd directly, in two hazard classes.
///
/// The first is builtins with no binary at all: `exit 7` as a script body
/// works today and must keep working. The second is builtins that *do*
/// have a binary whose behavior differs from the shell's — `echo -e`,
/// `printf`, and `test` all diverge between dash, bash, and coreutils, so
/// exec'ing the binary would silently change what a script does.
///
/// Sorted for `binary_search`; `builtin_list_is_sorted_and_deduped`
/// enforces that. Missing an entry is not a correctness hole on its own,
/// because an unresolvable word falls back to the shell anyway — this
/// list is what makes the common builtins *guaranteed* rather than
/// accidentally correct.
#[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", "{", "}",
];

/// Split a script body into `(program, args)` when it is a single plain
/// command that a shell would add nothing to. `None` means "use `sh`".
///
/// Recognizes only bodies built from [`EXTRA_ALLOWED`] bytes whose first
/// word is a bare program name. See the module docs for why this is a
/// scanner and not a parser.
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;
    }

    // Space is the only separator that survived the scan, so the split is
    // unambiguous — no quoting or escaping can be in play.
    let mut words = body.split_ascii_whitespace();
    let program = words.next()?;

    // A leading `FOO=bar` is a shell assignment prefix, not a program. `=`
    // is still fine in later words (`--target=es2020`).
    if program.contains('=') {
        return None;
    }
    // Looks like a flag, so we have misread the body somehow.
    if program.starts_with('-') {
        return None;
    }
    // A path-shaped program would make us reason about how std resolves a
    // relative program against `current_dir`. Real scripts invoke bare
    // names (`tsc`, `vitest`, `node`), so the case is not worth owning.
    if program.contains('/') {
        return None;
    }
    if SHELL_WORDS.binary_search(&program).is_ok() {
        return None;
    }

    Some((program, words.collect()))
}

/// What a PATH candidate is, from the point of view of "may we exec it
/// ourselves without changing what the script does".
enum Candidate {
    /// Executable by us, and the kernel can launch it directly.
    Runnable,
    /// Not a usable hit. Keep walking PATH, as a shell would.
    Miss,
    /// Exists and we could run it, but `sh` would do something else with
    /// it — so hand the whole body back to `sh`.
    DeferToShell,
}

/// Find `program` on `path`, mirroring how the shell would resolve it.
///
/// This is a correctness requirement, not an optimization: on Unix
/// `Command::new("tsc")` resolves through `execvp`, which searches the
/// *parent's* environ and ignores the `PATH` we hand the child — so
/// without resolving here ourselves, a `node_modules/.bin` program would
/// not be found at all.
///
/// Costs a `stat` and a 4-byte read per candidate until a hit (typically
/// one for a project-local bin, three for `node`), which is noise next to
/// the fork and shell startup it replaces. Deliberately uncached: a cache
/// would have to be invalidated on every install, and there is nothing to
/// win.
pub fn resolve_program(program: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
    for dir in std::env::split_paths(path) {
        // POSIX reads an empty entry as the cwd. Rather than reason about
        // a cwd-relative match, treat the whole search as inconclusive
        // and let the shell handle the body.
        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 {
    // `metadata` follows symlinks, so a `.bin/tsc -> ../pkg/cli.js` link
    // resolves to the real file.
    let Ok(meta) = std::fs::metadata(candidate) else {
        return Candidate::Miss;
    };
    if !meta.is_file() {
        return Candidate::Miss;
    }
    // Mode bits alone answer "is this marked executable", not "may *we*
    // execute it" — a file can carry `--x` for an owner we are not. A
    // shell keeps walking PATH in that case, so a hit we could not launch
    // must not end the search, or a later runnable entry gets shadowed by
    // an EACCES we would report as a spawn failure.
    if !can_execute(candidate, &meta) {
        return Candidate::Miss;
    }
    // `sh -c tool` runs an executable *without* a shebang or a native
    // header as a shell script; exec'ing it ourselves fails with
    // ENOEXEC. Only launch what the kernel can launch on its own and let
    // `sh` keep the rest, including its own interpretation of them.
    match launchable(candidate) {
        Some(true) => Candidate::Runnable,
        Some(false) => Candidate::DeferToShell,
        // Unreadable but executable (`--x`) is legal and the kernel may
        // well run it; we just cannot tell what it is, so we do not guess.
        None => Candidate::DeferToShell,
    }
}

/// Whether the file starts with `#!` or a native executable header —
/// i.e. whether `execve` alone can launch it.
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);
    }
    // ELF, the Mach-O 32/64-bit and fat variants, and PE. Matching
    // aube-linker's magic list without taking a dependency on it for four
    // byte comparisons.
    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;

    // `access(X_OK)` is what a shell's PATH search asks, so ask the same
    // question rather than re-deriving it from mode bits and our uid.
    let Ok(c_path) = std::ffi::CString::new(candidate.as_os_str().as_bytes()) else {
        return false;
    };
    // SAFETY: `c_path` is a valid NUL-terminated C string that outlives
    // the call, and `access` only reads it.
    unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 }
}

#[cfg(not(unix))]
fn can_execute(_candidate: &Path, _meta: &std::fs::Metadata) -> bool {
    // Only reachable from tests; the fast path itself is Unix-only.
    true
}

/// Whether `BASH_ENV` or `ENV` reaches the child, from either our own
/// environment or an embedder's `extra_env` contribution.
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))
    })
}

/// Plan a direct exec of `body` against `path`, or `None` to use `sh`.
///
/// Returns `(resolved_program, program_as_written, args)`. The second
/// element becomes `argv[0]`, matching what the shell would have passed.
pub fn direct_argv<'a>(
    body: &'a str,
    path: &std::ffi::OsStr,
) -> Option<(PathBuf, &'a str, Vec<&'a str>)> {
    // Windows would need PATHEXT plus the `.cmd`/`.ps1`/bare-sh shim
    // triple, and `CreateProcess` cannot run a `.cmd` at all — Windows
    // re-enters `cmd.exe` for batch files, so the process we skipped
    // comes right back. `cfg!` rather than `#[cfg]` so this module's
    // tests still compile and run on the Windows CI job.
    if cfg!(windows) {
        return None;
    }

    // Scan first. It is a pure pass over the body, where every check below
    // reads settings (cloning the snapshot) or the environment — so a body
    // that was always going to need a shell pays nothing for asking.
    let (program, args) = simple_command_argv(body)?;

    let settings = crate::script_settings();
    // The user pointed scripts at a specific shell; run them in it.
    if settings.script_shell.is_some() {
        return None;
    }
    // Signals intent about shell semantics even though aube does not
    // currently emulate one.
    if settings.shell_emulator {
        return None;
    }
    // Where `/bin/sh` is bash (macOS), bash sources `$BASH_ENV` for
    // non-interactive shells, so a script body can legitimately depend on
    // functions or PATH edits from that file. Same for `$ENV` under a
    // POSIX sh. If either is set, the shell is load-bearing.
    //
    // Check the child's effective environment, not just ours: an embedder
    // can contribute either var through `extra_env`, which
    // `apply_script_settings_env` stamps onto the command we are about to
    // build.
    if shell_init_var_set(&settings) {
        return None;
    }

    // Resolution failure is not an error — falling back to `sh` preserves
    // the shell's exit 127 and its exact `sh: 1: foo: not found` stderr,
    // and 126 for a hit that is not executable.
    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}");
        }
        // Spot-check full argv, including the `/`-containing later word
        // that the first-word `/` rule must not reject.
        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() {
        // Split out so a failure names the class. The first group has no
        // binary at all; the second has one that behaves differently.
        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() {
        // Pins the `"boom": "exit 7"` e2e fixture: `exit` has no binary,
        // so exec'ing it would turn a working script into ENOENT.
        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"
        );
    }

    /// Unique scratch dir. `tempfile` is deliberately not a dep of this
    /// crate (see `aborting_script_kills_grandchildren`), so follow the
    /// same `temp_dir` + nanos convention.
    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() {
        // `sh -c tool` runs this as a shell script; exec'ing it would fail
        // with ENOEXEC. Bail so the shell keeps interpreting it.
        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() {
        // Marked executable for a user we are not: a shell walks on to the
        // next PATH entry, so a later runnable entry must not be shadowed.
        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;
        // `--x------` with our uid stripped of the bit is not expressible
        // without changing owner, so use 0o100 and skip when running as
        // root (which bypasses the check entirely).
        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 {
            // root ignores permission bits; the first hit legitimately wins.
            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()
        };
        // An embedder can inject a shell init file through extra_env, and
        // `apply_script_settings_env` would stamp it on the child — so the
        // shell is load-bearing even though our own env is clean.
        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();
    }

    /// `direct_argv` reads the task-local settings snapshot, so drive it
    /// through `scope` the way `scoped_settings_tests` does rather than
    /// mutating the process-global fallback.
    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() {
        // `BASH_ENV` / `ENV` in the ambient environment legitimately veto
        // the fast path, so only assert the plan when this process is
        // clean. Reading them is why this is not a table with the two
        // decline cases above.
        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();
    }
}