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