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