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