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