Skip to main content

aube_scripts/
lib.rs

1//! Lifecycle script runner for aube.
2//!
3//! **Security model**:
4//! - Scripts from the **root package** (the project's own `package.json`)
5//!   run by default. They're written by the user, so they're trusted the
6//!   same way a user trusts `aube run <script>`.
7//! - Scripts from **installed dependencies** (e.g. `node-gyp` postinstall
8//!   from a native module) are SKIPPED by default. A package runs its
9//!   lifecycle scripts only if the active [`BuildPolicy`] allows it —
10//!   configured via `pnpm.allowBuilds` in `package.json`, `allowBuilds`
11//!   in `aube-workspace.yaml` (or `pnpm-workspace.yaml`), or the
12//!   escape-hatch `--dangerously-allow-all-builds` flag.
13//! - `--ignore-scripts` forces everything off, matching pnpm/npm.
14
15pub mod policy;
16
17#[cfg(target_os = "linux")]
18mod linux_jail;
19
20#[cfg(windows)]
21mod windows_job;
22
23pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
24
25use aube_manifest::PackageJson;
26use std::collections::hash_map::DefaultHasher;
27use std::hash::{Hash, Hasher};
28use std::path::{Path, PathBuf};
29
30/// Settings that affect every package-script shell aube spawns.
31#[derive(Debug, Clone, Default)]
32pub struct ScriptSettings {
33    pub node_options: Option<String>,
34    pub script_shell: Option<PathBuf>,
35    pub unsafe_perm: Option<bool>,
36    pub shell_emulator: bool,
37}
38
39/// Native build jail applied to dependency lifecycle scripts.
40#[derive(Debug, Clone)]
41pub struct ScriptJail {
42    pub package_dir: PathBuf,
43    pub env: Vec<String>,
44    pub read_paths: Vec<PathBuf>,
45    pub write_paths: Vec<PathBuf>,
46    pub network: bool,
47}
48
49impl ScriptJail {
50    pub fn new(package_dir: impl Into<PathBuf>) -> Self {
51        Self {
52            package_dir: package_dir.into(),
53            env: Vec::new(),
54            read_paths: Vec::new(),
55            write_paths: Vec::new(),
56            network: false,
57        }
58    }
59
60    pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
61        self.env = env.into_iter().collect();
62        self
63    }
64
65    pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
66        self.read_paths = paths.into_iter().collect();
67        self
68    }
69
70    pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
71        self.write_paths = paths.into_iter().collect();
72        self
73    }
74
75    pub fn with_network(mut self, network: bool) -> Self {
76        self.network = network;
77        self
78    }
79}
80
81pub struct ScriptJailHomeCleanup {
82    path: PathBuf,
83}
84
85impl ScriptJailHomeCleanup {
86    pub fn new(jail: &ScriptJail) -> Self {
87        Self {
88            path: jail_home(&jail.package_dir),
89        }
90    }
91}
92
93impl Drop for ScriptJailHomeCleanup {
94    fn drop(&mut self) {
95        if self.path.exists()
96            && let Err(err) = std::fs::remove_dir_all(&self.path)
97        {
98            tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
99        }
100    }
101}
102
103static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
104    std::sync::OnceLock::new();
105
106fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
107    SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
108}
109
110/// Replace the process-wide script settings snapshot. CLI commands call
111/// this after resolving `.npmrc` / workspace settings for the active
112/// project.
113pub fn set_script_settings(settings: ScriptSettings) {
114    match script_settings_lock().write() {
115        Ok(mut guard) => *guard = settings,
116        Err(poisoned) => *poisoned.into_inner() = settings,
117    }
118}
119
120fn script_settings() -> ScriptSettings {
121    match script_settings_lock().read() {
122        Ok(guard) => guard.clone(),
123        Err(poisoned) => poisoned.into_inner().clone(),
124    }
125}
126
127/// Prepend `bin_dir` to the current `PATH` using the platform's path
128/// separator (`:` on Unix, `;` on Windows).
129pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
130    let path = std::env::var_os("PATH").unwrap_or_default();
131    let mut entries = vec![bin_dir.to_path_buf()];
132    entries.extend(std::env::split_paths(&path));
133    std::env::join_paths(entries).unwrap_or(path)
134}
135
136/// Spawn a shell command line. On Unix we go through `sh -c`, on
137/// Windows through `cmd.exe /d /s /c` — matching what npm passes in
138/// `@npmcli/run-script`.
139///
140/// On Windows, the script command line is appended with
141/// [`std::os::windows::process::CommandExt::raw_arg`] instead of
142/// the normal `.arg()` path. `.arg()` would run the string through
143/// Rust's `CommandLineToArgvW`-oriented encoder, which wraps it in
144/// `"..."` and escapes interior `"` as `\"` — but `cmd.exe` parses
145/// command lines with a different set of rules and does not
146/// understand `\"`, so a script like
147/// `node -e "require('is-odd')(3)"` arrives mangled. `raw_arg`
148/// hands the command line to `CreateProcessW` verbatim, so we
149/// control the exact bytes cmd.exe sees. We wrap the whole script
150/// in an outer pair of double quotes, which `/s` tells cmd.exe to
151/// strip (just those outer quotes — the rest of the string is
152/// preserved literally). This is the same trick
153/// `@npmcli/run-script` and `node-cross-spawn` use.
154pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
155    let settings = script_settings();
156    spawn_shell_with_settings(script_cmd, &settings)
157}
158
159fn spawn_shell_with_settings(
160    script_cmd: &str,
161    settings: &ScriptSettings,
162) -> tokio::process::Command {
163    #[cfg(unix)]
164    let mut cmd = {
165        let mut cmd = tokio::process::Command::new(
166            settings
167                .script_shell
168                .as_deref()
169                .unwrap_or_else(|| Path::new("sh")),
170        );
171        cmd.arg("-c").arg(script_cmd);
172        cmd
173    };
174    #[cfg(windows)]
175    let mut cmd = {
176        let mut cmd = tokio::process::Command::new(
177            settings
178                .script_shell
179                .as_deref()
180                .unwrap_or_else(|| Path::new("cmd.exe")),
181        );
182        if settings.script_shell.is_some() {
183            cmd.arg("-c").arg(script_cmd);
184        } else {
185            // `/d` skips AutoRun, `/s` flips the quote-stripping rule
186            // so only the *outer* `"..."` pair is removed, `/c` runs
187            // the command and exits. Build the raw argv tail manually
188            // so cmd.exe sees the original script bytes.
189            cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
190        }
191        cmd
192    };
193    apply_script_settings_env(&mut cmd, settings);
194    // Aborting the `JoinSet` that drives the parallel lifecycle pass
195    // drops the spawned `Child`, which without `kill_on_drop` would
196    // leave the shell running detached (Discussion #654). On Windows
197    // that's only half the fix — `TerminateProcess` on `cmd.exe`
198    // doesn't reach grandchildren like `node-gyp` → `MSBuild` → `node`;
199    // [`run_command_killing_descendants`] also assigns the shell to a
200    // `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` job object to reap the
201    // whole tree.
202    cmd.kill_on_drop(true);
203    cmd
204}
205
206#[cfg(target_os = "macos")]
207fn sbpl_escape(s: &str) -> String {
208    s.replace('\\', "\\\\").replace('"', "\\\"")
209}
210
211#[cfg(target_os = "macos")]
212fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
213    let path = sbpl_escape(&path.to_string_lossy());
214    let rule = format!("(allow file-write* (subpath \"{path}\"))");
215    if !rules.iter().any(|existing| existing == &rule) {
216        rules.push(rule);
217    }
218}
219
220#[cfg(target_os = "macos")]
221fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
222    let mut rules = vec![
223        "(version 1)".to_string(),
224        "(allow default)".to_string(),
225        "(allow network* (local unix))".to_string(),
226        "(deny file-write*)".to_string(),
227    ];
228    if !jail.network {
229        rules.insert(2, "(deny network*)".to_string());
230    }
231
232    for path in [
233        Path::new("/tmp"),
234        Path::new("/private/tmp"),
235        Path::new("/dev"),
236    ] {
237        push_write_rule(&mut rules, path);
238    }
239    for path in [&jail.package_dir, home] {
240        push_write_rule(&mut rules, path);
241    }
242    for path in &jail.write_paths {
243        push_write_rule(&mut rules, path);
244    }
245    for path in [&jail.package_dir, home] {
246        if let Ok(canonical) = path.canonicalize() {
247            push_write_rule(&mut rules, &canonical);
248        }
249    }
250    for path in &jail.write_paths {
251        if let Ok(canonical) = path.canonicalize() {
252            push_write_rule(&mut rules, &canonical);
253        }
254    }
255    rules.join("\n")
256}
257
258#[cfg(target_os = "macos")]
259fn spawn_jailed_shell(
260    script_cmd: &str,
261    settings: &ScriptSettings,
262    jail: &ScriptJail,
263    home: &Path,
264) -> tokio::process::Command {
265    let shell = settings
266        .script_shell
267        .as_deref()
268        .unwrap_or_else(|| Path::new("sh"));
269    let profile = jail_profile(jail, home);
270    let mut cmd = tokio::process::Command::new("sandbox-exec");
271    cmd.arg("-p")
272        .arg(profile)
273        .arg("--")
274        .arg(shell)
275        .arg("-c")
276        .arg(script_cmd);
277    apply_script_settings_env(&mut cmd, settings);
278    // Matches the unjailed path — see `spawn_shell_with_settings`.
279    cmd.kill_on_drop(true);
280    cmd
281}
282
283#[cfg(target_os = "linux")]
284fn spawn_jailed_shell(
285    script_cmd: &str,
286    settings: &ScriptSettings,
287    jail: &ScriptJail,
288    home: &Path,
289) -> tokio::process::Command {
290    let mut cmd = spawn_shell_with_settings(script_cmd, settings);
291    let jail = jail.clone();
292    let home = home.to_path_buf();
293    unsafe {
294        cmd.pre_exec(move || {
295            linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
296            if !jail.network {
297                linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
298            }
299            Ok(())
300        });
301    }
302    cmd
303}
304
305#[cfg(not(any(target_os = "linux", target_os = "macos")))]
306fn spawn_jailed_shell(
307    script_cmd: &str,
308    settings: &ScriptSettings,
309    _jail: &ScriptJail,
310    _home: &Path,
311) -> tokio::process::Command {
312    spawn_shell_with_settings(script_cmd, settings)
313}
314
315/// Shell-quote one arg for safe splicing into a shell command line.
316///
317/// Used by `aube run <script> -- args`. Args get joined into the
318/// script string, then sh -c or cmd /c reparses the whole thing. If
319/// user arg contains $, backticks, ;, |, &, (, ), etc, the shell
320/// interprets those as metacharacters. That is shell injection.
321/// `aube run echo 'hello; rm -rf ~'` would run two commands. Same
322/// issue npm had pre-2016. Quote each arg so shell treats it as one
323/// literal token.
324///
325/// Unix: wrap in single quotes. sh treats interior of '...' as pure
326/// literal with one exception, embedded single quote. Handle that
327/// with the standard '\'' escape trick: close the single-quoted
328/// string, emit an escaped quote, reopen. Works in every POSIX sh.
329///
330/// Windows cmd.exe: wrap in double quotes. cmd interprets many
331/// metachars even inside double quotes, but CreateProcessW hands the
332/// string to our spawn_shell that uses `/d /s /c "..."`, the outer
333/// quotes get stripped per /s rule and the content runs. Escape
334/// interior " and backslash per CommandLineToArgvW. Full cmd.exe
335/// metachar caret-escaping is a rabbit hole, so this is best-effort,
336/// works for the common cases, matches what node's shell-quote does.
337pub fn shell_quote_arg(arg: &str) -> String {
338    #[cfg(unix)]
339    {
340        let mut out = String::with_capacity(arg.len() + 2);
341        out.push('\'');
342        for ch in arg.chars() {
343            if ch == '\'' {
344                out.push_str("'\\''");
345            } else {
346                out.push(ch);
347            }
348        }
349        out.push('\'');
350        out
351    }
352    #[cfg(windows)]
353    {
354        let mut out = String::with_capacity(arg.len() + 2);
355        out.push('"');
356        let mut backslashes: usize = 0;
357        for ch in arg.chars() {
358            match ch {
359                '\\' => backslashes += 1,
360                '"' => {
361                    for _ in 0..backslashes * 2 + 1 {
362                        out.push('\\');
363                    }
364                    out.push('"');
365                    backslashes = 0;
366                }
367                // cmd.exe expands %VAR% even inside double quotes.
368                // Outer `/s /c "..."` only strips the outermost
369                // quote pair, the shell still runs env expansion
370                // on the body. Argument like `%COMSPEC%` would
371                // otherwise get replaced with the shell path
372                // before the child saw it. Double the percent so
373                // cmd passes a literal `%` through. Full
374                // caret-escaping of `^ & | < > ( )` is a deeper
375                // rabbit hole, this handles the common injection
376                // vector.
377                '%' => {
378                    for _ in 0..backslashes {
379                        out.push('\\');
380                    }
381                    backslashes = 0;
382                    out.push_str("%%");
383                }
384                _ => {
385                    for _ in 0..backslashes {
386                        out.push('\\');
387                    }
388                    backslashes = 0;
389                    out.push(ch);
390                }
391            }
392        }
393        for _ in 0..backslashes * 2 {
394            out.push('\\');
395        }
396        out.push('"');
397        out
398    }
399}
400
401/// Translate child ExitStatus to a parent exit code.
402///
403/// On Unix a signal-killed child has None from .code(). Old code
404/// collapsed that to 1. That loses signal identity: SIGKILL (OOM
405/// killer, exit 137), SIGSEGV (139), Ctrl-C (130) all look like
406/// plain exit 1. CI pipelines watching for 137 to detect OOM cannot
407/// distinguish it from a normal script error anymore. Bash convention
408/// is 128 + signum, match that.
409///
410/// Windows has no signal concept so .code() is always Some, the
411/// fallback 1 is dead code there but keeps the function total.
412pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
413    if let Some(code) = status.code() {
414        return code;
415    }
416    #[cfg(unix)]
417    {
418        use std::os::unix::process::ExitStatusExt;
419        if let Some(sig) = status.signal() {
420            return 128 + sig;
421        }
422    }
423    1
424}
425
426/// User agent string exported to lifecycle scripts as
427/// `npm_config_user_agent`. Mirrors pnpm's format
428/// (`<name>/<version> <os> <arch>`) so dep build scripts that sniff
429/// the env var to detect the running PM (e.g. `husky`,
430/// `unrs-resolver`) recognize aube without falling back to npm-mode.
431/// OS/arch use Node's `process.platform` / `process.arch` vocabulary
432/// (`darwin`/`linux`/`win32`, `x64`/`arm64`), not Rust's native
433/// `std::env::consts::{OS,ARCH}` values, so tools that parse the full
434/// UA string identify the platform the same way npm/yarn/pnpm do.
435pub fn aube_user_agent() -> String {
436    format!(
437        "aube/{} {} {}",
438        env!("CARGO_PKG_VERSION"),
439        node_platform(),
440        node_arch(),
441    )
442}
443
444fn node_platform() -> &'static str {
445    match std::env::consts::OS {
446        "macos" => "darwin",
447        "windows" => "win32",
448        other => other,
449    }
450}
451
452fn node_arch() -> &'static str {
453    // Mappings from Rust's `std::env::consts::ARCH` to Node's
454    // `process.arch`. Common arches first; the rare ones at the bottom
455    // exist so the test below stays a real guarantee on every host
456    // Rust ships, not just x64/arm64. Pass-through covers `arm`,
457    // `mips`, `riscv64`, `s390x` — those tokens match between the two
458    // vocabularies.
459    match std::env::consts::ARCH {
460        "x86_64" => "x64",
461        "aarch64" => "arm64",
462        "x86" => "ia32",
463        "powerpc" => "ppc",
464        "powerpc64" => "ppc64",
465        "loongarch64" => "loong64",
466        other => other,
467    }
468}
469
470fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
471    // Strip credentials that aube itself owns before we spawn any
472    // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login
473    // token. No transitive postinstall has any business reading it.
474    // NPM_TOKEN and NODE_AUTH_TOKEN stay untouched because release
475    // flows ("npm publish" in a postpublish script) genuinely need
476    // them. Matches what pnpm does today.
477    cmd.env_remove("AUBE_AUTH_TOKEN");
478    // pnpm parity: every lifecycle script gets `npm_config_user_agent`
479    // so dep postinstalls can detect the running PM. Set here (not at
480    // spawn time) so it flows through both the jailed and the
481    // non-jailed paths.
482    cmd.env("npm_config_user_agent", aube_user_agent());
483    if let Some(node_options) = settings.node_options.as_deref() {
484        cmd.env("NODE_OPTIONS", node_options);
485    }
486    if let Some(unsafe_perm) = settings.unsafe_perm {
487        cmd.env(
488            "npm_config_unsafe_perm",
489            if unsafe_perm { "true" } else { "false" },
490        );
491    }
492    if settings.shell_emulator {
493        cmd.env("npm_config_shell_emulator", "true");
494    }
495}
496
497fn safe_jail_env_key(key: &str) -> bool {
498    const EXACT: &[&str] = &[
499        "PATH",
500        "HOME",
501        "TERM",
502        "LANG",
503        "LC_ALL",
504        "INIT_CWD",
505        "npm_lifecycle_event",
506        "npm_package_name",
507        "npm_package_version",
508    ];
509    if EXACT.contains(&key) {
510        return true;
511    }
512    let lower = key.to_ascii_lowercase();
513    if lower.contains("token")
514        || lower.contains("auth")
515        || lower.contains("password")
516        || lower.contains("credential")
517        || lower.contains("secret")
518    {
519        return false;
520    }
521    key.starts_with("npm_config_")
522}
523
524fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
525    (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
526        && !matches!(
527            key,
528            "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
529        )
530}
531
532fn jail_home(package_dir: &Path) -> PathBuf {
533    let mut hasher = DefaultHasher::new();
534    package_dir.hash(&mut hasher);
535    let hash = hasher.finish();
536    let name = package_dir
537        .file_name()
538        .and_then(|s| s.to_str())
539        .unwrap_or("package")
540        .chars()
541        .map(|c| {
542            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
543                c
544            } else {
545                '_'
546            }
547        })
548        .collect::<String>();
549    std::env::temp_dir()
550        .join("aube-jail")
551        .join(std::process::id().to_string())
552        .join(format!("{name}-{hash:016x}"))
553}
554
555fn apply_jail_env(
556    cmd: &mut tokio::process::Command,
557    path_env: &std::ffi::OsStr,
558    home: &Path,
559    project_root: &Path,
560    manifest: &PackageJson,
561    script_name: &str,
562    extra_env: &[String],
563) {
564    cmd.env_clear();
565    cmd.env("PATH", path_env)
566        .env("HOME", home)
567        .env("TMPDIR", home)
568        .env("TMP", home)
569        .env("TEMP", home)
570        .env("npm_lifecycle_event", script_name);
571    if std::env::var_os("INIT_CWD").is_none() {
572        cmd.env("INIT_CWD", project_root);
573    }
574    if let Some(ref name) = manifest.name {
575        cmd.env("npm_package_name", name);
576    }
577    if let Some(ref version) = manifest.version {
578        cmd.env("npm_package_version", version);
579    }
580    for (key, val) in std::env::vars_os() {
581        let Some(key_str) = key.to_str() else {
582            continue;
583        };
584        if inherit_jail_env_key(key_str, extra_env) {
585            cmd.env(key, val);
586        }
587    }
588}
589
590/// Lifecycle hooks that `aube install` runs against the root package's
591/// `scripts` field, in this order: `preinstall` → (dependencies link) →
592/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum LifecycleHook {
595    PreInstall,
596    Install,
597    PostInstall,
598    Prepare,
599}
600
601impl LifecycleHook {
602    pub fn script_name(self) -> &'static str {
603        match self {
604            Self::PreInstall => "preinstall",
605            Self::Install => "install",
606            Self::PostInstall => "postinstall",
607            Self::Prepare => "prepare",
608        }
609    }
610}
611
612/// Dependency lifecycle hooks, in the order aube runs them for each
613/// allowlisted package. `prepare` is intentionally omitted — it's meant
614/// for the root package and git-dep preparation, not installed tarballs.
615pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
616    LifecycleHook::PreInstall,
617    LifecycleHook::Install,
618    LifecycleHook::PostInstall,
619];
620
621/// Holds the real stderr fd saved before `aube` redirects fd 2 to
622/// `/dev/null` under `--silent`. Child processes spawned through
623/// `child_stderr()` get a fresh dup of this fd so their stderr still
624/// reaches the user's terminal — `--silent` only silences aube's own
625/// output, not the scripts / binaries it invokes (matches `pnpm
626/// --loglevel silent`). A value of `-1` means silent mode is off and
627/// children should inherit stderr normally.
628#[cfg(unix)]
629static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
630
631/// Called once by `aube` after it saves + redirects fd 2. Passing
632/// the caller-owned saved fd here means child processes spawned via
633/// `child_stderr()` will write to the real terminal stderr instead of
634/// `/dev/null`.
635#[cfg(unix)]
636pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
637    SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
638}
639
640/// Windows has no equivalent fd-based silencing plumbing: aube's
641/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
642/// calls are gated to unix in `aube`. The stub keeps the public
643/// API shape identical so call sites compile unchanged.
644#[cfg(not(unix))]
645pub fn set_saved_stderr_fd(_fd: i32) {}
646
647/// Returns a `Stdio` suitable for a child process's stderr. When silent
648/// mode is active, this dups the saved real-stderr fd so the child
649/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
650/// `Stdio::inherit()`.
651#[cfg(unix)]
652pub fn child_stderr() -> std::process::Stdio {
653    let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
654    if fd < 0 {
655        return std::process::Stdio::inherit();
656    }
657    // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
658    // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
659    // duration of main. `BorrowedFd` only borrows, so this does not
660    // transfer ownership.
661    let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
662    match borrowed.try_clone_to_owned() {
663        Ok(owned) => std::process::Stdio::from(owned),
664        Err(_) => std::process::Stdio::inherit(),
665    }
666}
667
668#[cfg(not(unix))]
669pub fn child_stderr() -> std::process::Stdio {
670    std::process::Stdio::inherit()
671}
672
673/// Write `line` plus a newline to the parent's real stderr. Used by
674/// the recursive-run output multiplexer, which pipes child stderr
675/// through aube and re-emits each line with a `<package>: ` prefix —
676/// `eprintln!` writes to fd 2, which `SilentStderrGuard` has redirected
677/// to `/dev/null` under `--silent`, so child stderr would otherwise be
678/// silently swallowed in `--silent --parallel` mode. Routes through the
679/// saved real-stderr fd when silent mode is active, fd 2 otherwise.
680///
681/// `write_all` of a pre-built `<line>\n` buffer issues a single short
682/// write to the kernel; on TTYs and pipes the kernel's `PIPE_BUF`
683/// (= 4096+ on every supported unix) atomicity keeps lines from
684/// concurrent pump tasks intact without explicit locking. The dup
685/// happens per line so we don't share a long-lived `File` handle that
686/// would need its own lock — a duplicate `write` syscall pair is
687/// cheaper than an `Arc<Mutex<File>>` and correct under concurrency.
688#[cfg(unix)]
689pub fn write_line_to_real_stderr(line: &str) {
690    use std::io::Write;
691    let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
692    let fd = if saved >= 0 { saved } else { 2 };
693    // SAFETY: `fd` is either the saved real-stderr fd (kept live by
694    // `SilentStderrGuard` for the duration of main) or fd 2 (always
695    // open). `BorrowedFd` only borrows; ownership stays with the
696    // saved-fd / std-stream side and `try_clone_to_owned` issues a
697    // `dup` so dropping the resulting `File` does not close fd 2 or
698    // the saved fd.
699    let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
700    let Ok(owned) = borrowed.try_clone_to_owned() else {
701        return;
702    };
703    let mut file = std::fs::File::from(owned);
704    let mut buf = String::with_capacity(line.len() + 1);
705    buf.push_str(line);
706    buf.push('\n');
707    let _ = file.write_all(buf.as_bytes());
708}
709
710#[cfg(not(unix))]
711pub fn write_line_to_real_stderr(line: &str) {
712    eprintln!("{line}");
713}
714
715/// Spawn `cmd`, wait for it, and on Windows attach the shell to a
716/// kill-on-job-close job object so an aborted lifecycle script reaps
717/// its full descendant tree instead of leaving orphans behind.
718///
719/// `kill_on_drop(true)` on the parent `Command` (set by
720/// [`spawn_shell_with_settings`]) covers `TerminateProcess` /
721/// `SIGKILL` on the direct shell. That alone is enough on Unix
722/// because most build tooling handles the parent dying — and the
723/// shell itself is the foreground process for the subscript pipeline.
724/// On Windows the shell's grandchildren (`node-gyp` → `MSBuild` →
725/// `node`) are *not* part of the shell's job by default, so killing
726/// the shell leaves them running detached. Discussion #654 is the
727/// in-the-wild bug: `aube add --global` failed, aube exited, and
728/// node/MSBuild kept writing to the console.
729///
730/// We mitigate by spawning, then assigning the child process handle
731/// to a job created with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The
732/// `_job` binding's `Drop` (called when this future returns, panics,
733/// or is aborted) closes the last job handle, and the kernel kills
734/// every assigned process — including everything the shell has
735/// spawned by that point. There is a microscopic race between spawn
736/// and `AssignProcessToJobObject`, but the shell does not have time
737/// to spawn anything in that window; the `tokio::process::Child`
738/// returns control to us synchronously after `CreateProcessW`
739/// returns.
740///
741/// Job-object failures are fail-open: restricted Windows environments
742/// (nested-job parents, container policy, handle quota) can refuse
743/// either `CreateJobObjectW` or `AssignProcessToJobObject`. In those
744/// cases we surface a `WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE`
745/// warning and run the script anyway — degrading to the
746/// `kill_on_drop`-only path that aube used before this fix. Failing
747/// closed would block lifecycle scripts entirely on those hosts,
748/// which is a worse regression than the orphaning we're trying to
749/// avoid.
750async fn run_command_killing_descendants(
751    mut cmd: tokio::process::Command,
752    script_name: &str,
753) -> Result<std::process::ExitStatus, Error> {
754    let mut child = cmd
755        .spawn()
756        .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
757    #[cfg(windows)]
758    let _job = match windows_job::JobObject::new() {
759        Ok(job) => {
760            // raw_handle() returns None only if the child has already
761            // been reaped, which can't happen between spawn() and the
762            // very next line.
763            if let Some(handle) = child.raw_handle()
764                && let Err(err) = job.assign(handle)
765            {
766                // Realistic causes: parent job created without
767                // JOB_OBJECT_LIMIT_BREAKAWAY_OK (pre-Win8 nested-job
768                // restrictions, enterprise policy), or the shell
769                // already exited. In either case the kill-tree
770                // guarantee is gone — log loud enough that CI logs
771                // pick it up.
772                tracing::warn!(
773                    code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
774                    "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
775                     grandchildren may be orphaned if the script is aborted"
776                );
777            }
778            Some(job)
779        }
780        Err(err) => {
781            tracing::warn!(
782                code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
783                "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
784                 running without orphan-reaping — grandchildren may leak if aborted"
785            );
786            None
787        }
788    };
789    child
790        .wait()
791        .await
792        .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
793}
794
795/// Run a single npm-style script line through `sh -c` with the usual
796/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
797/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
798///
799/// `extra_bin_dirs` are prepended to `PATH` in order, *before* the
800/// project-level `.bin`. Dep lifecycle scripts pass the dep's own
801/// sibling `node_modules/.bin/` so transitive binaries (e.g.
802/// `prebuild-install`, `node-gyp`) declared in the dep's
803/// `dependencies` are reachable, optionally followed by aube-owned
804/// tool dirs (e.g. the bootstrapped node-gyp). Root scripts pass
805/// `&[]` — their transitive bins are already hoisted into the
806/// project-level `.bin`.
807///
808/// Inherits stdio from the parent so the user sees script output live.
809/// Returns Err on non-zero exit so install fails fast if a lifecycle
810/// script breaks, matching pnpm.
811#[allow(clippy::too_many_arguments)]
812pub async fn run_script(
813    script_dir: &Path,
814    project_root: &Path,
815    modules_dir_name: &str,
816    manifest: &PackageJson,
817    script_name: &str,
818    script_cmd: &str,
819    extra_bin_dirs: &[&Path],
820    jail: Option<&ScriptJail>,
821) -> Result<(), Error> {
822    // Per-script diag span. Tags the package name (when present) and the
823    // script name so the analyzer can attribute postinstall / preinstall /
824    // build cost to the exact lifecycle entry rather than the aggregate
825    // `dep_lifecycle` phase total.
826    let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
827        .with_meta_fn(|| {
828            let pkg = manifest.name.as_deref().unwrap_or("(root)");
829            format!(
830                r#"{{"pkg":{},"script":{}}}"#,
831                aube_util::diag::jstr(pkg),
832                aube_util::diag::jstr(script_name)
833            )
834        });
835    // PATH prepends (most-local-first): `extra_bin_dirs` in caller
836    // order, then the project root's `<modules_dir>/.bin`. For root
837    // scripts `script_dir == project_root` and `extra_bin_dirs` is
838    // empty, which matches the old behavior. `modules_dir_name`
839    // honors pnpm's `modulesDir` setting — defaults to
840    // `"node_modules"` at the call site, but a workspace may have
841    // configured something else.
842    let project_bin = project_root.join(modules_dir_name).join(".bin");
843    let path = std::env::var_os("PATH").unwrap_or_default();
844    let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
845    for dir in extra_bin_dirs {
846        entries.push(dir.to_path_buf());
847    }
848    entries.push(project_bin);
849    entries.extend(std::env::split_paths(&path));
850    let new_path = std::env::join_paths(entries).unwrap_or(path);
851
852    let settings = script_settings();
853    let jail_home = jail.map(|j| jail_home(&j.package_dir));
854    if let Some(home) = &jail_home {
855        std::fs::create_dir_all(home)
856            .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
857    }
858    let mut cmd = match (jail, jail_home.as_deref()) {
859        (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
860        _ => spawn_shell_with_settings(script_cmd, &settings),
861    };
862    cmd.current_dir(script_dir)
863        .stderr(child_stderr())
864        .env("PATH", &new_path)
865        .env("npm_lifecycle_event", script_name);
866
867    // Pass INIT_CWD the way npm/pnpm do — the directory the user
868    // invoked the package manager from, *not* the script's own cwd.
869    // Native-module build tooling (node-gyp, prebuild-install, etc.)
870    // reads INIT_CWD to locate the project root when caching binaries.
871    // Preserve if already set by a parent aube invocation so nested
872    // scripts see the outermost cwd.
873    if std::env::var_os("INIT_CWD").is_none() {
874        cmd.env("INIT_CWD", project_root);
875    }
876
877    if let Some(ref name) = manifest.name {
878        cmd.env("npm_package_name", name);
879    }
880    if let Some(ref version) = manifest.version {
881        cmd.env("npm_package_version", version);
882    }
883    if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
884        apply_jail_env(
885            &mut cmd,
886            &new_path,
887            home,
888            project_root,
889            manifest,
890            script_name,
891            &jail.env,
892        );
893        apply_script_settings_env(&mut cmd, &settings);
894    }
895
896    tracing::debug!("lifecycle: {script_name} → {script_cmd}");
897    let status = run_command_killing_descendants(cmd, script_name).await?;
898
899    if !status.success() {
900        return Err(Error::NonZeroExit {
901            script: script_name.to_string(),
902            code: status.code(),
903        });
904    }
905
906    Ok(())
907}
908
909/// Run a lifecycle hook against the root package, if a script for it is
910/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
911/// `Ok(true)` if it ran successfully.
912///
913/// The caller is responsible for gating on `--ignore-scripts`.
914pub async fn run_root_hook(
915    project_dir: &Path,
916    modules_dir_name: &str,
917    manifest: &PackageJson,
918    hook: LifecycleHook,
919) -> Result<bool, Error> {
920    run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
921}
922
923/// Run a named root-package script if it's defined. Used by commands
924/// (pack, publish, version) that need to run lifecycle hooks outside
925/// the install-focused [`LifecycleHook`] enum. Returns `Ok(false)` if
926/// the script isn't defined.
927///
928/// The caller is responsible for gating on `--ignore-scripts`.
929pub async fn run_root_script_by_name(
930    project_dir: &Path,
931    modules_dir_name: &str,
932    manifest: &PackageJson,
933    name: &str,
934) -> Result<bool, Error> {
935    let Some(script_cmd) = manifest.scripts.get(name) else {
936        return Ok(false);
937    };
938    run_script(
939        project_dir,
940        project_dir,
941        modules_dir_name,
942        manifest,
943        name,
944        script_cmd,
945        &[],
946        None,
947    )
948    .await?;
949    Ok(true)
950}
951
952/// Single source of truth for the implicit `node-gyp rebuild`
953/// fallback: returns `Some("node-gyp rebuild")` when the package ships
954/// a `binding.gyp` at its root AND the manifest leaves both `install`
955/// and `preinstall` empty (either one is the author's explicit
956/// opt-out from the default).
957///
958/// `has_binding_gyp` is passed by the caller so this helper is
959/// agnostic to *how* presence was detected — the install pipeline
960/// stats the materialized package dir, while `aube ignored-builds`
961/// reads the store `PackageIndex` since the package may not be
962/// linked into `node_modules` yet. Both paths must agree on the gate
963/// condition, so they both go through this.
964pub fn implicit_install_script(
965    manifest: &PackageJson,
966    has_binding_gyp: bool,
967) -> Option<&'static str> {
968    if !has_binding_gyp {
969        return None;
970    }
971    if manifest
972        .scripts
973        .contains_key(LifecycleHook::Install.script_name())
974        || manifest
975            .scripts
976            .contains_key(LifecycleHook::PreInstall.script_name())
977    {
978        return None;
979    }
980    Some("node-gyp rebuild")
981}
982
983/// Default `install` command for a materialized dependency directory.
984/// Thin wrapper around [`implicit_install_script`] that supplies
985/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
986pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
987    implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
988}
989
990/// True if [`run_dep_hook`] would actually execute something for this
991/// package across any of the dependency lifecycle hooks. Callers use
992/// this to skip fan-out work for packages that have nothing to run —
993/// including the implicit `node-gyp rebuild` default.
994pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
995    if DEP_LIFECYCLE_HOOKS
996        .iter()
997        .any(|h| manifest.scripts.contains_key(h.script_name()))
998    {
999        return true;
1000    }
1001    default_install_script(package_dir, manifest).is_some()
1002}
1003
1004/// Run a lifecycle hook against an installed dependency's package
1005/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
1006/// (the actual linked package directory, e.g.
1007/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
1008/// is the dependency's own `package.json`, *not* the project root's.
1009///
1010/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
1011/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
1012/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
1013/// is prepended to `PATH` so the dep's postinstall can spawn tools
1014/// declared in its own `dependencies` (the transitive-bin case —
1015/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
1016/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
1017/// the same pass.
1018///
1019/// For the `install` hook specifically, if the manifest leaves both
1020/// `install` and `preinstall` empty but the package has a top-level
1021/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
1022/// node-gyp default that npm and pnpm both honor so native modules
1023/// without a prebuilt binary still compile on install.
1024///
1025/// `tool_bin_dirs` are prepended to `PATH` *after* the dep's own
1026/// `.bin` so that aube-bootstrapped tools (e.g. node-gyp) fill the
1027/// gap for deps that shell out to them without declaring them as
1028/// their own `dependencies`. The dep's local bin still wins if it
1029/// shipped its own copy.
1030///
1031/// The caller is responsible for gating on `BuildPolicy` and
1032/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
1033#[allow(clippy::too_many_arguments)]
1034pub async fn run_dep_hook(
1035    package_dir: &Path,
1036    dep_modules_dir: &Path,
1037    project_root: &Path,
1038    modules_dir_name: &str,
1039    manifest: &PackageJson,
1040    hook: LifecycleHook,
1041    tool_bin_dirs: &[&Path],
1042    jail: Option<&ScriptJail>,
1043) -> Result<bool, Error> {
1044    let name = hook.script_name();
1045    let script_cmd: &str = match manifest.scripts.get(name) {
1046        Some(s) => s.as_str(),
1047        None => match hook {
1048            LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1049                Some(s) => s,
1050                None => return Ok(false),
1051            },
1052            _ => return Ok(false),
1053        },
1054    };
1055    let dep_bin_dir = dep_modules_dir.join(".bin");
1056    let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1057    bin_dirs.push(&dep_bin_dir);
1058    bin_dirs.extend(tool_bin_dirs.iter().copied());
1059    run_script(
1060        package_dir,
1061        project_root,
1062        modules_dir_name,
1063        manifest,
1064        name,
1065        script_cmd,
1066        &bin_dirs,
1067        jail,
1068    )
1069    .await?;
1070    Ok(true)
1071}
1072
1073#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1074pub enum Error {
1075    #[error("failed to spawn script {0}: {1}")]
1076    #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1077    Spawn(String, String),
1078    #[error("script `{script}` exited with code {code:?}")]
1079    #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1080    NonZeroExit { script: String, code: Option<i32> },
1081}
1082
1083#[cfg(test)]
1084mod user_agent_tests {
1085    use super::*;
1086
1087    #[test]
1088    fn user_agent_uses_node_style_platform_and_arch() {
1089        let ua = aube_user_agent();
1090        // Format: "aube/<version> <platform> <arch>"
1091        assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1092        let parts: Vec<&str> = ua.split(' ').collect();
1093        assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1094        // Platform must be a Node-style token, not Rust's `macos`/`windows`.
1095        let platform = parts[1];
1096        assert!(
1097            matches!(
1098                platform,
1099                "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1100            ),
1101            "platform `{platform}` should follow Node's `process.platform` vocabulary"
1102        );
1103        // Arch must be a Node-style token, not Rust's `x86_64`/`aarch64`.
1104        // Allowlist is the union of mapped outputs (`node_arch`) and the
1105        // pass-through tokens that already match Node's vocabulary.
1106        let arch = parts[2];
1107        assert!(
1108            matches!(
1109                arch,
1110                "x64"
1111                    | "arm64"
1112                    | "ia32"
1113                    | "arm"
1114                    | "ppc"
1115                    | "ppc64"
1116                    | "loong64"
1117                    | "mips"
1118                    | "riscv64"
1119                    | "s390x"
1120            ),
1121            "arch `{arch}` should follow Node's `process.arch` vocabulary"
1122        );
1123    }
1124}
1125
1126#[cfg(test)]
1127mod jail_tests {
1128    use super::*;
1129
1130    #[test]
1131    fn jail_home_uses_full_package_path() {
1132        let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1133        let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1134
1135        assert_ne!(a, b);
1136        assert!(
1137            a.file_name()
1138                .unwrap()
1139                .to_string_lossy()
1140                .starts_with("native-")
1141        );
1142        assert!(
1143            b.file_name()
1144                .unwrap()
1145                .to_string_lossy()
1146                .starts_with("native-")
1147        );
1148    }
1149
1150    #[test]
1151    fn jail_home_cleanup_removes_temp_home() {
1152        let package_dir = std::env::temp_dir()
1153            .join("aube-jail-cleanup-test")
1154            .join(std::process::id().to_string())
1155            .join("node_modules")
1156            .join("native");
1157        let jail = ScriptJail::new(&package_dir);
1158        let home = jail_home(&package_dir);
1159        std::fs::create_dir_all(home.join(".cache")).unwrap();
1160        std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1161
1162        {
1163            let _cleanup = ScriptJailHomeCleanup::new(&jail);
1164        }
1165
1166        assert!(!home.exists());
1167    }
1168
1169    #[test]
1170    fn parent_env_cannot_override_explicit_jail_metadata() {
1171        for key in [
1172            "PATH",
1173            "HOME",
1174            "npm_lifecycle_event",
1175            "npm_package_name",
1176            "npm_package_version",
1177        ] {
1178            assert!(!inherit_jail_env_key(key, &[]));
1179        }
1180        assert!(inherit_jail_env_key("INIT_CWD", &[]));
1181        assert!(inherit_jail_env_key("npm_config_arch", &[]));
1182        assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1183        assert!(inherit_jail_env_key(
1184            "SHARP_DIST_BASE_URL",
1185            &["SHARP_DIST_BASE_URL".to_string()]
1186        ));
1187    }
1188
1189    #[test]
1190    fn jail_env_preserves_script_settings_after_clear() {
1191        let mut cmd = tokio::process::Command::new("node");
1192        let manifest = PackageJson {
1193            name: Some("pkg".to_string()),
1194            version: Some("1.2.3".to_string()),
1195            ..Default::default()
1196        };
1197        let settings = ScriptSettings {
1198            node_options: Some("--conditions=aube".to_string()),
1199            unsafe_perm: Some(false),
1200            shell_emulator: true,
1201            ..Default::default()
1202        };
1203
1204        apply_jail_env(
1205            &mut cmd,
1206            std::ffi::OsStr::new("/bin"),
1207            Path::new("/tmp/aube-jail/home"),
1208            Path::new("/tmp/project"),
1209            &manifest,
1210            "postinstall",
1211            &[],
1212        );
1213        apply_script_settings_env(&mut cmd, &settings);
1214
1215        let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1216        let env = |name: &str| {
1217            envs.iter()
1218                .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1219                .and_then(|(_, val)| *val)
1220                .and_then(|val| val.to_str())
1221        };
1222
1223        assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1224        assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1225        assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1226        assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1227        assert_eq!(env("npm_package_name"), Some("pkg"));
1228        assert_eq!(env("npm_package_version"), Some("1.2.3"));
1229    }
1230}
1231
1232#[cfg(all(test, windows))]
1233mod windows_quote_tests {
1234    use super::shell_quote_arg;
1235
1236    #[test]
1237    fn windows_path_backslash_not_doubled() {
1238        let q = shell_quote_arg(r"C:\Users\me\file.txt");
1239        assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1240    }
1241
1242    #[test]
1243    fn windows_trailing_backslash_doubled_before_close_quote() {
1244        let q = shell_quote_arg(r"C:\path\");
1245        assert_eq!(q, "\"C:\\path\\\\\"");
1246    }
1247
1248    #[test]
1249    fn windows_quote_in_arg_escapes_with_backslash() {
1250        assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1251        assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1252        assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1253    }
1254}
1255
1256// Regression test for Discussion #654: aborting the lifecycle JoinSet
1257// after a failed `aube add --global` left node-gyp / MSBuild / node
1258// running orphaned on Windows because `TerminateProcess` on the cmd.exe
1259// shell does not propagate to its descendants. The Job Object the
1260// spawn helper now attaches the shell to must reap the entire process
1261// tree when the parent future is dropped.
1262#[cfg(all(test, windows))]
1263mod windows_job_object_tests {
1264    use super::*;
1265    use std::time::{Duration, Instant};
1266    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1267    use windows_sys::Win32::System::Threading::{
1268        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1269    };
1270
1271    fn is_process_alive(pid: u32) -> bool {
1272        // SAFETY: documented entry points; we close any handle we
1273        // successfully obtain. `OpenProcess` returns NULL once the
1274        // pid has been reaped or never existed.
1275        unsafe {
1276            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1277            if handle.is_null() {
1278                return false;
1279            }
1280            let mut code: u32 = 0;
1281            let ok = GetExitCodeProcess(handle, &mut code);
1282            CloseHandle(handle);
1283            ok != 0 && code == STILL_ACTIVE as u32
1284        }
1285    }
1286
1287    async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1288        let start = Instant::now();
1289        while !check() {
1290            if start.elapsed() > timeout {
1291                return false;
1292            }
1293            tokio::time::sleep(Duration::from_millis(75)).await;
1294        }
1295        true
1296    }
1297
1298    #[tokio::test]
1299    async fn aborting_script_kills_grandchildren() {
1300        // Unique pid-file path per test run so concurrent test
1301        // executions don't stomp each other. `tempfile` is not a
1302        // dep of this crate; std::env::temp_dir + nanos is enough.
1303        let nanos = std::time::SystemTime::now()
1304            .duration_since(std::time::UNIX_EPOCH)
1305            .unwrap_or_default()
1306            .as_nanos();
1307        let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1308        // Background a hidden powershell that writes its own PID
1309        // and then sleeps long enough that the test will fail if it
1310        // isn't reaped. `start /b` detaches the powershell from the
1311        // cmd.exe shell — exactly the orphaned-grandchild shape that
1312        // node-gyp / MSBuild produce in Discussion #654. The trailing
1313        // `ping` keeps the shell itself alive for ~8s so the test
1314        // can race a liveness check against the running grandchild
1315        // before aborting the parent future.
1316        let script = format!(
1317            "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1318             \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1319             & ping -n 10 127.0.0.1 >nul",
1320            pid_file.display()
1321        );
1322        let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1323        let task = tokio::spawn(async move {
1324            let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1325        });
1326
1327        let appeared = wait_until(|| pid_file.exists(), Duration::from_secs(20)).await;
1328        assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1329        let pid: u32 = std::fs::read_to_string(&pid_file)
1330            .expect("read pid file")
1331            .trim()
1332            .parse()
1333            .expect("parse pid");
1334        assert!(
1335            is_process_alive(pid),
1336            "grandchild pid {pid} not alive immediately after writing pid file"
1337        );
1338
1339        // Drop the future mid-`child.wait().await`. The `_job` local
1340        // in `run_command_killing_descendants` drops with it, which
1341        // closes the last handle and fires `KILL_ON_JOB_CLOSE` —
1342        // killing both the shell *and* the detached powershell.
1343        task.abort();
1344        let _ = task.await;
1345
1346        let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1347        let _ = std::fs::remove_file(&pid_file);
1348        assert!(
1349            reaped,
1350            "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1351        );
1352    }
1353}