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 `pnpm-workspace.yaml`, or the escape-hatch
12//!   `--dangerously-allow-all-builds` flag.
13//! - `--ignore-scripts` forces everything off, matching pnpm/npm.
14
15pub mod policy;
16
17pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
18
19use aube_manifest::PackageJson;
20use std::collections::hash_map::DefaultHasher;
21use std::hash::{Hash, Hasher};
22use std::path::{Path, PathBuf};
23
24/// Settings that affect every package-script shell aube spawns.
25#[derive(Debug, Clone, Default)]
26pub struct ScriptSettings {
27    pub node_options: Option<String>,
28    pub script_shell: Option<PathBuf>,
29    pub unsafe_perm: Option<bool>,
30    pub shell_emulator: bool,
31}
32
33/// Native build jail applied to dependency lifecycle scripts.
34#[derive(Debug, Clone)]
35pub struct ScriptJail {
36    pub package_dir: PathBuf,
37    pub env: Vec<String>,
38    pub read_paths: Vec<PathBuf>,
39    pub write_paths: Vec<PathBuf>,
40    pub network: bool,
41}
42
43impl ScriptJail {
44    pub fn new(package_dir: impl Into<PathBuf>) -> Self {
45        Self {
46            package_dir: package_dir.into(),
47            env: Vec::new(),
48            read_paths: Vec::new(),
49            write_paths: Vec::new(),
50            network: false,
51        }
52    }
53
54    pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
55        self.env = env.into_iter().collect();
56        self
57    }
58
59    pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
60        self.read_paths = paths.into_iter().collect();
61        self
62    }
63
64    pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
65        self.write_paths = paths.into_iter().collect();
66        self
67    }
68
69    pub fn with_network(mut self, network: bool) -> Self {
70        self.network = network;
71        self
72    }
73}
74
75pub struct ScriptJailHomeCleanup {
76    path: PathBuf,
77}
78
79impl ScriptJailHomeCleanup {
80    pub fn new(jail: &ScriptJail) -> Self {
81        Self {
82            path: jail_home(&jail.package_dir),
83        }
84    }
85}
86
87impl Drop for ScriptJailHomeCleanup {
88    fn drop(&mut self) {
89        if self.path.exists()
90            && let Err(err) = std::fs::remove_dir_all(&self.path)
91        {
92            tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
93        }
94    }
95}
96
97static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
98    std::sync::OnceLock::new();
99
100fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
101    SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
102}
103
104/// Replace the process-wide script settings snapshot. CLI commands call
105/// this after resolving `.npmrc` / workspace settings for the active
106/// project.
107pub fn set_script_settings(settings: ScriptSettings) {
108    match script_settings_lock().write() {
109        Ok(mut guard) => *guard = settings,
110        Err(poisoned) => *poisoned.into_inner() = settings,
111    }
112}
113
114fn script_settings() -> ScriptSettings {
115    match script_settings_lock().read() {
116        Ok(guard) => guard.clone(),
117        Err(poisoned) => poisoned.into_inner().clone(),
118    }
119}
120
121/// Prepend `bin_dir` to the current `PATH` using the platform's path
122/// separator (`:` on Unix, `;` on Windows).
123pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
124    let path = std::env::var_os("PATH").unwrap_or_default();
125    let mut entries = vec![bin_dir.to_path_buf()];
126    entries.extend(std::env::split_paths(&path));
127    std::env::join_paths(entries).unwrap_or(path)
128}
129
130/// Spawn a shell command line. On Unix we go through `sh -c`, on
131/// Windows through `cmd.exe /d /s /c` — matching what npm passes in
132/// `@npmcli/run-script`.
133///
134/// On Windows, the script command line is appended with
135/// [`std::os::windows::process::CommandExt::raw_arg`] instead of
136/// the normal `.arg()` path. `.arg()` would run the string through
137/// Rust's `CommandLineToArgvW`-oriented encoder, which wraps it in
138/// `"..."` and escapes interior `"` as `\"` — but `cmd.exe` parses
139/// command lines with a different set of rules and does not
140/// understand `\"`, so a script like
141/// `node -e "require('is-odd')(3)"` arrives mangled. `raw_arg`
142/// hands the command line to `CreateProcessW` verbatim, so we
143/// control the exact bytes cmd.exe sees. We wrap the whole script
144/// in an outer pair of double quotes, which `/s` tells cmd.exe to
145/// strip (just those outer quotes — the rest of the string is
146/// preserved literally). This is the same trick
147/// `@npmcli/run-script` and `node-cross-spawn` use.
148pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
149    let settings = script_settings();
150    spawn_shell_with_settings(script_cmd, &settings)
151}
152
153fn spawn_shell_with_settings(
154    script_cmd: &str,
155    settings: &ScriptSettings,
156) -> tokio::process::Command {
157    #[cfg(unix)]
158    {
159        let mut cmd = tokio::process::Command::new(
160            settings
161                .script_shell
162                .as_deref()
163                .unwrap_or_else(|| Path::new("sh")),
164        );
165        cmd.arg("-c").arg(script_cmd);
166        apply_script_settings_env(&mut cmd, settings);
167        cmd
168    }
169    #[cfg(windows)]
170    {
171        let mut cmd = tokio::process::Command::new(
172            settings
173                .script_shell
174                .as_deref()
175                .unwrap_or_else(|| Path::new("cmd.exe")),
176        );
177        if settings.script_shell.is_some() {
178            cmd.arg("-c").arg(script_cmd);
179        } else {
180            // `/d` skips AutoRun, `/s` flips the quote-stripping rule
181            // so only the *outer* `"..."` pair is removed, `/c` runs
182            // the command and exits. Build the raw argv tail manually
183            // so cmd.exe sees the original script bytes.
184            cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
185        }
186        apply_script_settings_env(&mut cmd, &settings);
187        cmd
188    }
189}
190
191#[cfg(target_os = "macos")]
192fn sbpl_escape(s: &str) -> String {
193    s.replace('\\', "\\\\").replace('"', "\\\"")
194}
195
196#[cfg(target_os = "macos")]
197fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
198    let path = sbpl_escape(&path.to_string_lossy());
199    let rule = format!("(allow file-write* (subpath \"{path}\"))");
200    if !rules.iter().any(|existing| existing == &rule) {
201        rules.push(rule);
202    }
203}
204
205#[cfg(target_os = "macos")]
206fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
207    let mut rules = vec![
208        "(version 1)".to_string(),
209        "(allow default)".to_string(),
210        "(allow network* (local unix))".to_string(),
211        "(deny file-write*)".to_string(),
212    ];
213    if !jail.network {
214        rules.insert(2, "(deny network*)".to_string());
215    }
216
217    for path in [
218        Path::new("/tmp"),
219        Path::new("/private/tmp"),
220        Path::new("/dev"),
221    ] {
222        push_write_rule(&mut rules, path);
223    }
224    for path in [&jail.package_dir, home] {
225        push_write_rule(&mut rules, path);
226    }
227    for path in &jail.write_paths {
228        push_write_rule(&mut rules, path);
229    }
230    for path in [&jail.package_dir, home] {
231        if let Ok(canonical) = path.canonicalize() {
232            push_write_rule(&mut rules, &canonical);
233        }
234    }
235    for path in &jail.write_paths {
236        if let Ok(canonical) = path.canonicalize() {
237            push_write_rule(&mut rules, &canonical);
238        }
239    }
240    rules.join("\n")
241}
242
243#[cfg(target_os = "macos")]
244fn spawn_jailed_shell(
245    script_cmd: &str,
246    settings: &ScriptSettings,
247    jail: &ScriptJail,
248    home: &Path,
249) -> tokio::process::Command {
250    let shell = settings
251        .script_shell
252        .as_deref()
253        .unwrap_or_else(|| Path::new("sh"));
254    let profile = jail_profile(jail, home);
255    let mut cmd = tokio::process::Command::new("sandbox-exec");
256    cmd.arg("-p")
257        .arg(profile)
258        .arg("--")
259        .arg(shell)
260        .arg("-c")
261        .arg(script_cmd);
262    apply_script_settings_env(&mut cmd, settings);
263    cmd
264}
265
266#[cfg(not(target_os = "macos"))]
267fn spawn_jailed_shell(
268    script_cmd: &str,
269    settings: &ScriptSettings,
270    _jail: &ScriptJail,
271    _home: &Path,
272) -> tokio::process::Command {
273    spawn_shell_with_settings(script_cmd, settings)
274}
275
276/// Shell-quote one arg for safe splicing into a shell command line.
277///
278/// Used by `aube run <script> -- args`. Args get joined into the
279/// script string, then sh -c or cmd /c reparses the whole thing. If
280/// user arg contains $, backticks, ;, |, &, (, ), etc, the shell
281/// interprets those as metacharacters. That is shell injection.
282/// `aube run echo 'hello; rm -rf ~'` would run two commands. Same
283/// issue npm had pre-2016. Quote each arg so shell treats it as one
284/// literal token.
285///
286/// Unix: wrap in single quotes. sh treats interior of '...' as pure
287/// literal with one exception, embedded single quote. Handle that
288/// with the standard '\'' escape trick: close the single-quoted
289/// string, emit an escaped quote, reopen. Works in every POSIX sh.
290///
291/// Windows cmd.exe: wrap in double quotes. cmd interprets many
292/// metachars even inside double quotes, but CreateProcessW hands the
293/// string to our spawn_shell that uses `/d /s /c "..."`, the outer
294/// quotes get stripped per /s rule and the content runs. Escape
295/// interior " and backslash per CommandLineToArgvW. Full cmd.exe
296/// metachar caret-escaping is a rabbit hole, so this is best-effort,
297/// works for the common cases, matches what node's shell-quote does.
298pub fn shell_quote_arg(arg: &str) -> String {
299    #[cfg(unix)]
300    {
301        let mut out = String::with_capacity(arg.len() + 2);
302        out.push('\'');
303        for ch in arg.chars() {
304            if ch == '\'' {
305                out.push_str("'\\''");
306            } else {
307                out.push(ch);
308            }
309        }
310        out.push('\'');
311        out
312    }
313    #[cfg(windows)]
314    {
315        let mut out = String::with_capacity(arg.len() + 2);
316        out.push('"');
317        let mut backslashes: usize = 0;
318        for ch in arg.chars() {
319            match ch {
320                '\\' => backslashes += 1,
321                '"' => {
322                    for _ in 0..backslashes * 2 + 1 {
323                        out.push('\\');
324                    }
325                    out.push('"');
326                    backslashes = 0;
327                }
328                // cmd.exe expands %VAR% even inside double quotes.
329                // Outer `/s /c "..."` only strips the outermost
330                // quote pair, the shell still runs env expansion
331                // on the body. Argument like `%COMSPEC%` would
332                // otherwise get replaced with the shell path
333                // before the child saw it. Double the percent so
334                // cmd passes a literal `%` through. Full
335                // caret-escaping of `^ & | < > ( )` is a deeper
336                // rabbit hole, this handles the common injection
337                // vector.
338                '%' => {
339                    for _ in 0..backslashes {
340                        out.push('\\');
341                    }
342                    backslashes = 0;
343                    out.push_str("%%");
344                }
345                _ => {
346                    for _ in 0..backslashes {
347                        out.push('\\');
348                    }
349                    backslashes = 0;
350                    out.push(ch);
351                }
352            }
353        }
354        for _ in 0..backslashes * 2 {
355            out.push('\\');
356        }
357        out.push('"');
358        out
359    }
360}
361
362/// Translate child ExitStatus to a parent exit code.
363///
364/// On Unix a signal-killed child has None from .code(). Old code
365/// collapsed that to 1. That loses signal identity: SIGKILL (OOM
366/// killer, exit 137), SIGSEGV (139), Ctrl-C (130) all look like
367/// plain exit 1. CI pipelines watching for 137 to detect OOM cannot
368/// distinguish it from a normal script error anymore. Bash convention
369/// is 128 + signum, match that.
370///
371/// Windows has no signal concept so .code() is always Some, the
372/// fallback 1 is dead code there but keeps the function total.
373pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
374    if let Some(code) = status.code() {
375        return code;
376    }
377    #[cfg(unix)]
378    {
379        use std::os::unix::process::ExitStatusExt;
380        if let Some(sig) = status.signal() {
381            return 128 + sig;
382        }
383    }
384    1
385}
386
387fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
388    // Strip credentials that aube itself owns before we spawn any
389    // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login
390    // token. No transitive postinstall has any business reading it.
391    // NPM_TOKEN and NODE_AUTH_TOKEN stay untouched because release
392    // flows ("npm publish" in a postpublish script) genuinely need
393    // them. Matches what pnpm does today.
394    cmd.env_remove("AUBE_AUTH_TOKEN");
395    if let Some(node_options) = settings.node_options.as_deref() {
396        cmd.env("NODE_OPTIONS", node_options);
397    }
398    if let Some(unsafe_perm) = settings.unsafe_perm {
399        cmd.env(
400            "npm_config_unsafe_perm",
401            if unsafe_perm { "true" } else { "false" },
402        );
403    }
404    if settings.shell_emulator {
405        cmd.env("npm_config_shell_emulator", "true");
406    }
407}
408
409fn safe_jail_env_key(key: &str) -> bool {
410    const EXACT: &[&str] = &[
411        "PATH",
412        "HOME",
413        "TERM",
414        "LANG",
415        "LC_ALL",
416        "INIT_CWD",
417        "npm_lifecycle_event",
418        "npm_package_name",
419        "npm_package_version",
420    ];
421    if EXACT.contains(&key) {
422        return true;
423    }
424    let lower = key.to_ascii_lowercase();
425    if lower.contains("token")
426        || lower.contains("auth")
427        || lower.contains("password")
428        || lower.contains("credential")
429        || lower.contains("secret")
430    {
431        return false;
432    }
433    key.starts_with("npm_config_")
434}
435
436fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
437    (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
438        && !matches!(
439            key,
440            "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
441        )
442}
443
444fn jail_home(package_dir: &Path) -> PathBuf {
445    let mut hasher = DefaultHasher::new();
446    package_dir.hash(&mut hasher);
447    let hash = hasher.finish();
448    let name = package_dir
449        .file_name()
450        .and_then(|s| s.to_str())
451        .unwrap_or("package")
452        .chars()
453        .map(|c| {
454            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
455                c
456            } else {
457                '_'
458            }
459        })
460        .collect::<String>();
461    std::env::temp_dir()
462        .join("aube-jail")
463        .join(std::process::id().to_string())
464        .join(format!("{name}-{hash:016x}"))
465}
466
467fn apply_jail_env(
468    cmd: &mut tokio::process::Command,
469    path_env: &std::ffi::OsStr,
470    home: &Path,
471    project_root: &Path,
472    manifest: &PackageJson,
473    script_name: &str,
474    extra_env: &[String],
475) {
476    cmd.env_clear();
477    cmd.env("PATH", path_env)
478        .env("HOME", home)
479        .env("npm_lifecycle_event", script_name);
480    if std::env::var_os("INIT_CWD").is_none() {
481        cmd.env("INIT_CWD", project_root);
482    }
483    if let Some(ref name) = manifest.name {
484        cmd.env("npm_package_name", name);
485    }
486    if let Some(ref version) = manifest.version {
487        cmd.env("npm_package_version", version);
488    }
489    for (key, val) in std::env::vars_os() {
490        let Some(key_str) = key.to_str() else {
491            continue;
492        };
493        if inherit_jail_env_key(key_str, extra_env) {
494            cmd.env(key, val);
495        }
496    }
497}
498
499/// Lifecycle hooks that `aube install` runs against the root package's
500/// `scripts` field, in this order: `preinstall` → (dependencies link) →
501/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum LifecycleHook {
504    PreInstall,
505    Install,
506    PostInstall,
507    Prepare,
508}
509
510impl LifecycleHook {
511    pub fn script_name(self) -> &'static str {
512        match self {
513            Self::PreInstall => "preinstall",
514            Self::Install => "install",
515            Self::PostInstall => "postinstall",
516            Self::Prepare => "prepare",
517        }
518    }
519}
520
521/// Dependency lifecycle hooks, in the order aube runs them for each
522/// allowlisted package. `prepare` is intentionally omitted — it's meant
523/// for the root package and git-dep preparation, not installed tarballs.
524pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
525    LifecycleHook::PreInstall,
526    LifecycleHook::Install,
527    LifecycleHook::PostInstall,
528];
529
530/// Holds the real stderr fd saved before `aube` redirects fd 2 to
531/// `/dev/null` under `--silent`. Child processes spawned through
532/// `child_stderr()` get a fresh dup of this fd so their stderr still
533/// reaches the user's terminal — `--silent` only silences aube's own
534/// output, not the scripts / binaries it invokes (matches `pnpm
535/// --loglevel silent`). A value of `-1` means silent mode is off and
536/// children should inherit stderr normally.
537#[cfg(unix)]
538static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
539
540/// Called once by `aube` after it saves + redirects fd 2. Passing
541/// the caller-owned saved fd here means child processes spawned via
542/// `child_stderr()` will write to the real terminal stderr instead of
543/// `/dev/null`.
544#[cfg(unix)]
545pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
546    SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
547}
548
549/// Windows has no equivalent fd-based silencing plumbing: aube's
550/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
551/// calls are gated to unix in `aube`. The stub keeps the public
552/// API shape identical so call sites compile unchanged.
553#[cfg(not(unix))]
554pub fn set_saved_stderr_fd(_fd: i32) {}
555
556/// Returns a `Stdio` suitable for a child process's stderr. When silent
557/// mode is active, this dups the saved real-stderr fd so the child
558/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
559/// `Stdio::inherit()`.
560#[cfg(unix)]
561pub fn child_stderr() -> std::process::Stdio {
562    let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
563    if fd < 0 {
564        return std::process::Stdio::inherit();
565    }
566    // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
567    // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
568    // duration of main. `BorrowedFd` only borrows, so this does not
569    // transfer ownership.
570    let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
571    match borrowed.try_clone_to_owned() {
572        Ok(owned) => std::process::Stdio::from(owned),
573        Err(_) => std::process::Stdio::inherit(),
574    }
575}
576
577#[cfg(not(unix))]
578pub fn child_stderr() -> std::process::Stdio {
579    std::process::Stdio::inherit()
580}
581
582/// Run a single npm-style script line through `sh -c` with the usual
583/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
584/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
585///
586/// `extra_bin_dirs` are prepended to `PATH` in order, *before* the
587/// project-level `.bin`. Dep lifecycle scripts pass the dep's own
588/// sibling `node_modules/.bin/` so transitive binaries (e.g.
589/// `prebuild-install`, `node-gyp`) declared in the dep's
590/// `dependencies` are reachable, optionally followed by aube-owned
591/// tool dirs (e.g. the bootstrapped node-gyp). Root scripts pass
592/// `&[]` — their transitive bins are already hoisted into the
593/// project-level `.bin`.
594///
595/// Inherits stdio from the parent so the user sees script output live.
596/// Returns Err on non-zero exit so install fails fast if a lifecycle
597/// script breaks, matching pnpm.
598#[allow(clippy::too_many_arguments)]
599pub async fn run_script(
600    script_dir: &Path,
601    project_root: &Path,
602    modules_dir_name: &str,
603    manifest: &PackageJson,
604    script_name: &str,
605    script_cmd: &str,
606    extra_bin_dirs: &[&Path],
607    jail: Option<&ScriptJail>,
608) -> Result<(), Error> {
609    // PATH prepends (most-local-first): `extra_bin_dirs` in caller
610    // order, then the project root's `<modules_dir>/.bin`. For root
611    // scripts `script_dir == project_root` and `extra_bin_dirs` is
612    // empty, which matches the old behavior. `modules_dir_name`
613    // honors pnpm's `modulesDir` setting — defaults to
614    // `"node_modules"` at the call site, but a workspace may have
615    // configured something else.
616    let project_bin = project_root.join(modules_dir_name).join(".bin");
617    let path = std::env::var_os("PATH").unwrap_or_default();
618    let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
619    for dir in extra_bin_dirs {
620        entries.push(dir.to_path_buf());
621    }
622    entries.push(project_bin);
623    entries.extend(std::env::split_paths(&path));
624    let new_path = std::env::join_paths(entries).unwrap_or(path);
625
626    let settings = script_settings();
627    let jail_home = jail.map(|j| jail_home(&j.package_dir));
628    if let Some(home) = &jail_home {
629        std::fs::create_dir_all(home)
630            .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
631    }
632    let mut cmd = match (jail, jail_home.as_deref()) {
633        (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
634        _ => spawn_shell_with_settings(script_cmd, &settings),
635    };
636    cmd.current_dir(script_dir)
637        .stderr(child_stderr())
638        .env("PATH", &new_path)
639        .env("npm_lifecycle_event", script_name);
640
641    // Pass INIT_CWD the way npm/pnpm do — the directory the user
642    // invoked the package manager from, *not* the script's own cwd.
643    // Native-module build tooling (node-gyp, prebuild-install, etc.)
644    // reads INIT_CWD to locate the project root when caching binaries.
645    // Preserve if already set by a parent aube invocation so nested
646    // scripts see the outermost cwd.
647    if std::env::var_os("INIT_CWD").is_none() {
648        cmd.env("INIT_CWD", project_root);
649    }
650
651    if let Some(ref name) = manifest.name {
652        cmd.env("npm_package_name", name);
653    }
654    if let Some(ref version) = manifest.version {
655        cmd.env("npm_package_version", version);
656    }
657    if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
658        apply_jail_env(
659            &mut cmd,
660            &new_path,
661            home,
662            project_root,
663            manifest,
664            script_name,
665            &jail.env,
666        );
667        apply_script_settings_env(&mut cmd, &settings);
668    }
669
670    tracing::debug!("lifecycle: {script_name} → {script_cmd}");
671    let status = cmd
672        .status()
673        .await
674        .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
675
676    if !status.success() {
677        return Err(Error::NonZeroExit {
678            script: script_name.to_string(),
679            code: status.code(),
680        });
681    }
682
683    Ok(())
684}
685
686/// Run a lifecycle hook against the root package, if a script for it is
687/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
688/// `Ok(true)` if it ran successfully.
689///
690/// The caller is responsible for gating on `--ignore-scripts`.
691pub async fn run_root_hook(
692    project_dir: &Path,
693    modules_dir_name: &str,
694    manifest: &PackageJson,
695    hook: LifecycleHook,
696) -> Result<bool, Error> {
697    run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
698}
699
700/// Run a named root-package script if it's defined. Used by commands
701/// (pack, publish, version) that need to run lifecycle hooks outside
702/// the install-focused [`LifecycleHook`] enum. Returns `Ok(false)` if
703/// the script isn't defined.
704///
705/// The caller is responsible for gating on `--ignore-scripts`.
706pub async fn run_root_script_by_name(
707    project_dir: &Path,
708    modules_dir_name: &str,
709    manifest: &PackageJson,
710    name: &str,
711) -> Result<bool, Error> {
712    let Some(script_cmd) = manifest.scripts.get(name) else {
713        return Ok(false);
714    };
715    run_script(
716        project_dir,
717        project_dir,
718        modules_dir_name,
719        manifest,
720        name,
721        script_cmd,
722        &[],
723        None,
724    )
725    .await?;
726    Ok(true)
727}
728
729/// Single source of truth for the implicit `node-gyp rebuild`
730/// fallback: returns `Some("node-gyp rebuild")` when the package ships
731/// a `binding.gyp` at its root AND the manifest leaves both `install`
732/// and `preinstall` empty (either one is the author's explicit
733/// opt-out from the default).
734///
735/// `has_binding_gyp` is passed by the caller so this helper is
736/// agnostic to *how* presence was detected — the install pipeline
737/// stats the materialized package dir, while `aube ignored-builds`
738/// reads the store `PackageIndex` since the package may not be
739/// linked into `node_modules` yet. Both paths must agree on the gate
740/// condition, so they both go through this.
741pub fn implicit_install_script(
742    manifest: &PackageJson,
743    has_binding_gyp: bool,
744) -> Option<&'static str> {
745    if !has_binding_gyp {
746        return None;
747    }
748    if manifest
749        .scripts
750        .contains_key(LifecycleHook::Install.script_name())
751        || manifest
752            .scripts
753            .contains_key(LifecycleHook::PreInstall.script_name())
754    {
755        return None;
756    }
757    Some("node-gyp rebuild")
758}
759
760/// Default `install` command for a materialized dependency directory.
761/// Thin wrapper around [`implicit_install_script`] that supplies
762/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
763pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
764    implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
765}
766
767/// True if [`run_dep_hook`] would actually execute something for this
768/// package across any of the dependency lifecycle hooks. Callers use
769/// this to skip fan-out work for packages that have nothing to run —
770/// including the implicit `node-gyp rebuild` default.
771pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
772    if DEP_LIFECYCLE_HOOKS
773        .iter()
774        .any(|h| manifest.scripts.contains_key(h.script_name()))
775    {
776        return true;
777    }
778    default_install_script(package_dir, manifest).is_some()
779}
780
781/// Run a lifecycle hook against an installed dependency's package
782/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
783/// (the actual linked package directory, e.g.
784/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
785/// is the dependency's own `package.json`, *not* the project root's.
786///
787/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
788/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
789/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
790/// is prepended to `PATH` so the dep's postinstall can spawn tools
791/// declared in its own `dependencies` (the transitive-bin case —
792/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
793/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
794/// the same pass.
795///
796/// For the `install` hook specifically, if the manifest leaves both
797/// `install` and `preinstall` empty but the package has a top-level
798/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
799/// node-gyp default that npm and pnpm both honor so native modules
800/// without a prebuilt binary still compile on install.
801///
802/// `tool_bin_dirs` are prepended to `PATH` *after* the dep's own
803/// `.bin` so that aube-bootstrapped tools (e.g. node-gyp) fill the
804/// gap for deps that shell out to them without declaring them as
805/// their own `dependencies`. The dep's local bin still wins if it
806/// shipped its own copy.
807///
808/// The caller is responsible for gating on `BuildPolicy` and
809/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
810#[allow(clippy::too_many_arguments)]
811pub async fn run_dep_hook(
812    package_dir: &Path,
813    dep_modules_dir: &Path,
814    project_root: &Path,
815    modules_dir_name: &str,
816    manifest: &PackageJson,
817    hook: LifecycleHook,
818    tool_bin_dirs: &[&Path],
819    jail: Option<&ScriptJail>,
820) -> Result<bool, Error> {
821    let name = hook.script_name();
822    let script_cmd: &str = match manifest.scripts.get(name) {
823        Some(s) => s.as_str(),
824        None => match hook {
825            LifecycleHook::Install => match default_install_script(package_dir, manifest) {
826                Some(s) => s,
827                None => return Ok(false),
828            },
829            _ => return Ok(false),
830        },
831    };
832    let dep_bin_dir = dep_modules_dir.join(".bin");
833    let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
834    bin_dirs.push(&dep_bin_dir);
835    bin_dirs.extend(tool_bin_dirs.iter().copied());
836    run_script(
837        package_dir,
838        project_root,
839        modules_dir_name,
840        manifest,
841        name,
842        script_cmd,
843        &bin_dirs,
844        jail,
845    )
846    .await?;
847    Ok(true)
848}
849
850#[derive(Debug, thiserror::Error)]
851pub enum Error {
852    #[error("failed to spawn script {0}: {1}")]
853    Spawn(String, String),
854    #[error("script `{script}` exited with code {code:?}")]
855    NonZeroExit { script: String, code: Option<i32> },
856}
857
858#[cfg(test)]
859mod jail_tests {
860    use super::*;
861
862    #[test]
863    fn jail_home_uses_full_package_path() {
864        let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
865        let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
866
867        assert_ne!(a, b);
868        assert!(
869            a.file_name()
870                .unwrap()
871                .to_string_lossy()
872                .starts_with("native-")
873        );
874        assert!(
875            b.file_name()
876                .unwrap()
877                .to_string_lossy()
878                .starts_with("native-")
879        );
880    }
881
882    #[test]
883    fn jail_home_cleanup_removes_temp_home() {
884        let package_dir = std::env::temp_dir()
885            .join("aube-jail-cleanup-test")
886            .join(std::process::id().to_string())
887            .join("node_modules")
888            .join("native");
889        let jail = ScriptJail::new(&package_dir);
890        let home = jail_home(&package_dir);
891        std::fs::create_dir_all(home.join(".cache")).unwrap();
892        std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
893
894        {
895            let _cleanup = ScriptJailHomeCleanup::new(&jail);
896        }
897
898        assert!(!home.exists());
899    }
900
901    #[test]
902    fn parent_env_cannot_override_explicit_jail_metadata() {
903        for key in [
904            "PATH",
905            "HOME",
906            "npm_lifecycle_event",
907            "npm_package_name",
908            "npm_package_version",
909        ] {
910            assert!(!inherit_jail_env_key(key, &[]));
911        }
912        assert!(inherit_jail_env_key("INIT_CWD", &[]));
913        assert!(inherit_jail_env_key("npm_config_arch", &[]));
914        assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
915        assert!(inherit_jail_env_key(
916            "SHARP_DIST_BASE_URL",
917            &["SHARP_DIST_BASE_URL".to_string()]
918        ));
919    }
920
921    #[test]
922    fn jail_env_preserves_script_settings_after_clear() {
923        let mut cmd = tokio::process::Command::new("node");
924        let manifest = PackageJson {
925            name: Some("pkg".to_string()),
926            version: Some("1.2.3".to_string()),
927            ..Default::default()
928        };
929        let settings = ScriptSettings {
930            node_options: Some("--conditions=aube".to_string()),
931            unsafe_perm: Some(false),
932            shell_emulator: true,
933            ..Default::default()
934        };
935
936        apply_jail_env(
937            &mut cmd,
938            std::ffi::OsStr::new("/bin"),
939            Path::new("/tmp/aube-jail/home"),
940            Path::new("/tmp/project"),
941            &manifest,
942            "postinstall",
943            &[],
944        );
945        apply_script_settings_env(&mut cmd, &settings);
946
947        let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
948        let env = |name: &str| {
949            envs.iter()
950                .find(|(key, _)| *key == std::ffi::OsStr::new(name))
951                .and_then(|(_, val)| *val)
952                .and_then(|val| val.to_str())
953        };
954
955        assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
956        assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
957        assert_eq!(env("npm_config_shell_emulator"), Some("true"));
958        assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
959        assert_eq!(env("npm_package_name"), Some("pkg"));
960        assert_eq!(env("npm_package_version"), Some("1.2.3"));
961    }
962}
963
964#[cfg(all(test, windows))]
965mod windows_quote_tests {
966    use super::shell_quote_arg;
967
968    #[test]
969    fn windows_path_backslash_not_doubled() {
970        let q = shell_quote_arg(r"C:\Users\me\file.txt");
971        assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
972    }
973
974    #[test]
975    fn windows_trailing_backslash_doubled_before_close_quote() {
976        let q = shell_quote_arg(r"C:\path\");
977        assert_eq!(q, "\"C:\\path\\\\\"");
978    }
979
980    #[test]
981    fn windows_quote_in_arg_escapes_with_backslash() {
982        assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
983        assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
984        assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
985    }
986}