Skip to main content

aube_scripts/
lib.rs

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