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