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