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