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