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 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
412/// User agent string exported to lifecycle scripts as
413/// `npm_config_user_agent`. Mirrors pnpm's format
414/// (`<name>/<version> <os> <arch>`) so dep build scripts that sniff
415/// the env var to detect the running PM (e.g. `husky`,
416/// `unrs-resolver`) recognize aube without falling back to npm-mode.
417/// OS/arch use Node's `process.platform` / `process.arch` vocabulary
418/// (`darwin`/`linux`/`win32`, `x64`/`arm64`), not Rust's native
419/// `std::env::consts::{OS,ARCH}` values, so tools that parse the full
420/// UA string identify the platform the same way npm/yarn/pnpm do.
421pub fn aube_user_agent() -> String {
422    format!(
423        "aube/{} {} {}",
424        env!("CARGO_PKG_VERSION"),
425        node_platform(),
426        node_arch(),
427    )
428}
429
430fn node_platform() -> &'static str {
431    match std::env::consts::OS {
432        "macos" => "darwin",
433        "windows" => "win32",
434        other => other,
435    }
436}
437
438fn node_arch() -> &'static str {
439    // Mappings from Rust's `std::env::consts::ARCH` to Node's
440    // `process.arch`. Common arches first; the rare ones at the bottom
441    // exist so the test below stays a real guarantee on every host
442    // Rust ships, not just x64/arm64. Pass-through covers `arm`,
443    // `mips`, `riscv64`, `s390x` — those tokens match between the two
444    // vocabularies.
445    match std::env::consts::ARCH {
446        "x86_64" => "x64",
447        "aarch64" => "arm64",
448        "x86" => "ia32",
449        "powerpc" => "ppc",
450        "powerpc64" => "ppc64",
451        "loongarch64" => "loong64",
452        other => other,
453    }
454}
455
456fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
457    // Strip credentials that aube itself owns before we spawn any
458    // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login
459    // token. No transitive postinstall has any business reading it.
460    // NPM_TOKEN and NODE_AUTH_TOKEN stay untouched because release
461    // flows ("npm publish" in a postpublish script) genuinely need
462    // them. Matches what pnpm does today.
463    cmd.env_remove("AUBE_AUTH_TOKEN");
464    // pnpm parity: every lifecycle script gets `npm_config_user_agent`
465    // so dep postinstalls can detect the running PM. Set here (not at
466    // spawn time) so it flows through both the jailed and the
467    // non-jailed paths.
468    cmd.env("npm_config_user_agent", aube_user_agent());
469    if let Some(node_options) = settings.node_options.as_deref() {
470        cmd.env("NODE_OPTIONS", node_options);
471    }
472    if let Some(unsafe_perm) = settings.unsafe_perm {
473        cmd.env(
474            "npm_config_unsafe_perm",
475            if unsafe_perm { "true" } else { "false" },
476        );
477    }
478    if settings.shell_emulator {
479        cmd.env("npm_config_shell_emulator", "true");
480    }
481}
482
483fn safe_jail_env_key(key: &str) -> bool {
484    const EXACT: &[&str] = &[
485        "PATH",
486        "HOME",
487        "TERM",
488        "LANG",
489        "LC_ALL",
490        "INIT_CWD",
491        "npm_lifecycle_event",
492        "npm_package_name",
493        "npm_package_version",
494    ];
495    if EXACT.contains(&key) {
496        return true;
497    }
498    let lower = key.to_ascii_lowercase();
499    if lower.contains("token")
500        || lower.contains("auth")
501        || lower.contains("password")
502        || lower.contains("credential")
503        || lower.contains("secret")
504    {
505        return false;
506    }
507    key.starts_with("npm_config_")
508}
509
510fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
511    (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
512        && !matches!(
513            key,
514            "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
515        )
516}
517
518fn jail_home(package_dir: &Path) -> PathBuf {
519    let mut hasher = DefaultHasher::new();
520    package_dir.hash(&mut hasher);
521    let hash = hasher.finish();
522    let name = package_dir
523        .file_name()
524        .and_then(|s| s.to_str())
525        .unwrap_or("package")
526        .chars()
527        .map(|c| {
528            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
529                c
530            } else {
531                '_'
532            }
533        })
534        .collect::<String>();
535    std::env::temp_dir()
536        .join("aube-jail")
537        .join(std::process::id().to_string())
538        .join(format!("{name}-{hash:016x}"))
539}
540
541fn apply_jail_env(
542    cmd: &mut tokio::process::Command,
543    path_env: &std::ffi::OsStr,
544    home: &Path,
545    project_root: &Path,
546    manifest: &PackageJson,
547    script_name: &str,
548    extra_env: &[String],
549) {
550    cmd.env_clear();
551    cmd.env("PATH", path_env)
552        .env("HOME", home)
553        .env("TMPDIR", home)
554        .env("TMP", home)
555        .env("TEMP", home)
556        .env("npm_lifecycle_event", script_name);
557    if std::env::var_os("INIT_CWD").is_none() {
558        cmd.env("INIT_CWD", project_root);
559    }
560    if let Some(ref name) = manifest.name {
561        cmd.env("npm_package_name", name);
562    }
563    if let Some(ref version) = manifest.version {
564        cmd.env("npm_package_version", version);
565    }
566    for (key, val) in std::env::vars_os() {
567        let Some(key_str) = key.to_str() else {
568            continue;
569        };
570        if inherit_jail_env_key(key_str, extra_env) {
571            cmd.env(key, val);
572        }
573    }
574}
575
576/// Lifecycle hooks that `aube install` runs against the root package's
577/// `scripts` field, in this order: `preinstall` → (dependencies link) →
578/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub enum LifecycleHook {
581    PreInstall,
582    Install,
583    PostInstall,
584    Prepare,
585}
586
587impl LifecycleHook {
588    pub fn script_name(self) -> &'static str {
589        match self {
590            Self::PreInstall => "preinstall",
591            Self::Install => "install",
592            Self::PostInstall => "postinstall",
593            Self::Prepare => "prepare",
594        }
595    }
596}
597
598/// Dependency lifecycle hooks, in the order aube runs them for each
599/// allowlisted package. `prepare` is intentionally omitted — it's meant
600/// for the root package and git-dep preparation, not installed tarballs.
601pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
602    LifecycleHook::PreInstall,
603    LifecycleHook::Install,
604    LifecycleHook::PostInstall,
605];
606
607/// Holds the real stderr fd saved before `aube` redirects fd 2 to
608/// `/dev/null` under `--silent`. Child processes spawned through
609/// `child_stderr()` get a fresh dup of this fd so their stderr still
610/// reaches the user's terminal — `--silent` only silences aube's own
611/// output, not the scripts / binaries it invokes (matches `pnpm
612/// --loglevel silent`). A value of `-1` means silent mode is off and
613/// children should inherit stderr normally.
614#[cfg(unix)]
615static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
616
617/// Called once by `aube` after it saves + redirects fd 2. Passing
618/// the caller-owned saved fd here means child processes spawned via
619/// `child_stderr()` will write to the real terminal stderr instead of
620/// `/dev/null`.
621#[cfg(unix)]
622pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
623    SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
624}
625
626/// Windows has no equivalent fd-based silencing plumbing: aube's
627/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
628/// calls are gated to unix in `aube`. The stub keeps the public
629/// API shape identical so call sites compile unchanged.
630#[cfg(not(unix))]
631pub fn set_saved_stderr_fd(_fd: i32) {}
632
633/// Returns a `Stdio` suitable for a child process's stderr. When silent
634/// mode is active, this dups the saved real-stderr fd so the child
635/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
636/// `Stdio::inherit()`.
637#[cfg(unix)]
638pub fn child_stderr() -> std::process::Stdio {
639    let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
640    if fd < 0 {
641        return std::process::Stdio::inherit();
642    }
643    // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
644    // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
645    // duration of main. `BorrowedFd` only borrows, so this does not
646    // transfer ownership.
647    let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
648    match borrowed.try_clone_to_owned() {
649        Ok(owned) => std::process::Stdio::from(owned),
650        Err(_) => std::process::Stdio::inherit(),
651    }
652}
653
654#[cfg(not(unix))]
655pub fn child_stderr() -> std::process::Stdio {
656    std::process::Stdio::inherit()
657}
658
659/// Run a single npm-style script line through `sh -c` with the usual
660/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
661/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
662///
663/// `extra_bin_dirs` are prepended to `PATH` in order, *before* the
664/// project-level `.bin`. Dep lifecycle scripts pass the dep's own
665/// sibling `node_modules/.bin/` so transitive binaries (e.g.
666/// `prebuild-install`, `node-gyp`) declared in the dep's
667/// `dependencies` are reachable, optionally followed by aube-owned
668/// tool dirs (e.g. the bootstrapped node-gyp). Root scripts pass
669/// `&[]` — their transitive bins are already hoisted into the
670/// project-level `.bin`.
671///
672/// Inherits stdio from the parent so the user sees script output live.
673/// Returns Err on non-zero exit so install fails fast if a lifecycle
674/// script breaks, matching pnpm.
675#[allow(clippy::too_many_arguments)]
676pub async fn run_script(
677    script_dir: &Path,
678    project_root: &Path,
679    modules_dir_name: &str,
680    manifest: &PackageJson,
681    script_name: &str,
682    script_cmd: &str,
683    extra_bin_dirs: &[&Path],
684    jail: Option<&ScriptJail>,
685) -> Result<(), Error> {
686    // PATH prepends (most-local-first): `extra_bin_dirs` in caller
687    // order, then the project root's `<modules_dir>/.bin`. For root
688    // scripts `script_dir == project_root` and `extra_bin_dirs` is
689    // empty, which matches the old behavior. `modules_dir_name`
690    // honors pnpm's `modulesDir` setting — defaults to
691    // `"node_modules"` at the call site, but a workspace may have
692    // configured something else.
693    let project_bin = project_root.join(modules_dir_name).join(".bin");
694    let path = std::env::var_os("PATH").unwrap_or_default();
695    let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
696    for dir in extra_bin_dirs {
697        entries.push(dir.to_path_buf());
698    }
699    entries.push(project_bin);
700    entries.extend(std::env::split_paths(&path));
701    let new_path = std::env::join_paths(entries).unwrap_or(path);
702
703    let settings = script_settings();
704    let jail_home = jail.map(|j| jail_home(&j.package_dir));
705    if let Some(home) = &jail_home {
706        std::fs::create_dir_all(home)
707            .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
708    }
709    let mut cmd = match (jail, jail_home.as_deref()) {
710        (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
711        _ => spawn_shell_with_settings(script_cmd, &settings),
712    };
713    cmd.current_dir(script_dir)
714        .stderr(child_stderr())
715        .env("PATH", &new_path)
716        .env("npm_lifecycle_event", script_name);
717
718    // Pass INIT_CWD the way npm/pnpm do — the directory the user
719    // invoked the package manager from, *not* the script's own cwd.
720    // Native-module build tooling (node-gyp, prebuild-install, etc.)
721    // reads INIT_CWD to locate the project root when caching binaries.
722    // Preserve if already set by a parent aube invocation so nested
723    // scripts see the outermost cwd.
724    if std::env::var_os("INIT_CWD").is_none() {
725        cmd.env("INIT_CWD", project_root);
726    }
727
728    if let Some(ref name) = manifest.name {
729        cmd.env("npm_package_name", name);
730    }
731    if let Some(ref version) = manifest.version {
732        cmd.env("npm_package_version", version);
733    }
734    if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
735        apply_jail_env(
736            &mut cmd,
737            &new_path,
738            home,
739            project_root,
740            manifest,
741            script_name,
742            &jail.env,
743        );
744        apply_script_settings_env(&mut cmd, &settings);
745    }
746
747    tracing::debug!("lifecycle: {script_name} → {script_cmd}");
748    let status = cmd
749        .status()
750        .await
751        .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
752
753    if !status.success() {
754        return Err(Error::NonZeroExit {
755            script: script_name.to_string(),
756            code: status.code(),
757        });
758    }
759
760    Ok(())
761}
762
763/// Run a lifecycle hook against the root package, if a script for it is
764/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
765/// `Ok(true)` if it ran successfully.
766///
767/// The caller is responsible for gating on `--ignore-scripts`.
768pub async fn run_root_hook(
769    project_dir: &Path,
770    modules_dir_name: &str,
771    manifest: &PackageJson,
772    hook: LifecycleHook,
773) -> Result<bool, Error> {
774    run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
775}
776
777/// Run a named root-package script if it's defined. Used by commands
778/// (pack, publish, version) that need to run lifecycle hooks outside
779/// the install-focused [`LifecycleHook`] enum. Returns `Ok(false)` if
780/// the script isn't defined.
781///
782/// The caller is responsible for gating on `--ignore-scripts`.
783pub async fn run_root_script_by_name(
784    project_dir: &Path,
785    modules_dir_name: &str,
786    manifest: &PackageJson,
787    name: &str,
788) -> Result<bool, Error> {
789    let Some(script_cmd) = manifest.scripts.get(name) else {
790        return Ok(false);
791    };
792    run_script(
793        project_dir,
794        project_dir,
795        modules_dir_name,
796        manifest,
797        name,
798        script_cmd,
799        &[],
800        None,
801    )
802    .await?;
803    Ok(true)
804}
805
806/// Single source of truth for the implicit `node-gyp rebuild`
807/// fallback: returns `Some("node-gyp rebuild")` when the package ships
808/// a `binding.gyp` at its root AND the manifest leaves both `install`
809/// and `preinstall` empty (either one is the author's explicit
810/// opt-out from the default).
811///
812/// `has_binding_gyp` is passed by the caller so this helper is
813/// agnostic to *how* presence was detected — the install pipeline
814/// stats the materialized package dir, while `aube ignored-builds`
815/// reads the store `PackageIndex` since the package may not be
816/// linked into `node_modules` yet. Both paths must agree on the gate
817/// condition, so they both go through this.
818pub fn implicit_install_script(
819    manifest: &PackageJson,
820    has_binding_gyp: bool,
821) -> Option<&'static str> {
822    if !has_binding_gyp {
823        return None;
824    }
825    if manifest
826        .scripts
827        .contains_key(LifecycleHook::Install.script_name())
828        || manifest
829            .scripts
830            .contains_key(LifecycleHook::PreInstall.script_name())
831    {
832        return None;
833    }
834    Some("node-gyp rebuild")
835}
836
837/// Default `install` command for a materialized dependency directory.
838/// Thin wrapper around [`implicit_install_script`] that supplies
839/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
840pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
841    implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
842}
843
844/// True if [`run_dep_hook`] would actually execute something for this
845/// package across any of the dependency lifecycle hooks. Callers use
846/// this to skip fan-out work for packages that have nothing to run —
847/// including the implicit `node-gyp rebuild` default.
848pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
849    if DEP_LIFECYCLE_HOOKS
850        .iter()
851        .any(|h| manifest.scripts.contains_key(h.script_name()))
852    {
853        return true;
854    }
855    default_install_script(package_dir, manifest).is_some()
856}
857
858/// Run a lifecycle hook against an installed dependency's package
859/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
860/// (the actual linked package directory, e.g.
861/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
862/// is the dependency's own `package.json`, *not* the project root's.
863///
864/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
865/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
866/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
867/// is prepended to `PATH` so the dep's postinstall can spawn tools
868/// declared in its own `dependencies` (the transitive-bin case —
869/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
870/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
871/// the same pass.
872///
873/// For the `install` hook specifically, if the manifest leaves both
874/// `install` and `preinstall` empty but the package has a top-level
875/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
876/// node-gyp default that npm and pnpm both honor so native modules
877/// without a prebuilt binary still compile on install.
878///
879/// `tool_bin_dirs` are prepended to `PATH` *after* the dep's own
880/// `.bin` so that aube-bootstrapped tools (e.g. node-gyp) fill the
881/// gap for deps that shell out to them without declaring them as
882/// their own `dependencies`. The dep's local bin still wins if it
883/// shipped its own copy.
884///
885/// The caller is responsible for gating on `BuildPolicy` and
886/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
887#[allow(clippy::too_many_arguments)]
888pub async fn run_dep_hook(
889    package_dir: &Path,
890    dep_modules_dir: &Path,
891    project_root: &Path,
892    modules_dir_name: &str,
893    manifest: &PackageJson,
894    hook: LifecycleHook,
895    tool_bin_dirs: &[&Path],
896    jail: Option<&ScriptJail>,
897) -> Result<bool, Error> {
898    let name = hook.script_name();
899    let script_cmd: &str = match manifest.scripts.get(name) {
900        Some(s) => s.as_str(),
901        None => match hook {
902            LifecycleHook::Install => match default_install_script(package_dir, manifest) {
903                Some(s) => s,
904                None => return Ok(false),
905            },
906            _ => return Ok(false),
907        },
908    };
909    let dep_bin_dir = dep_modules_dir.join(".bin");
910    let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
911    bin_dirs.push(&dep_bin_dir);
912    bin_dirs.extend(tool_bin_dirs.iter().copied());
913    run_script(
914        package_dir,
915        project_root,
916        modules_dir_name,
917        manifest,
918        name,
919        script_cmd,
920        &bin_dirs,
921        jail,
922    )
923    .await?;
924    Ok(true)
925}
926
927#[derive(Debug, thiserror::Error, miette::Diagnostic)]
928pub enum Error {
929    #[error("failed to spawn script {0}: {1}")]
930    #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
931    Spawn(String, String),
932    #[error("script `{script}` exited with code {code:?}")]
933    #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
934    NonZeroExit { script: String, code: Option<i32> },
935}
936
937#[cfg(test)]
938mod user_agent_tests {
939    use super::*;
940
941    #[test]
942    fn user_agent_uses_node_style_platform_and_arch() {
943        let ua = aube_user_agent();
944        // Format: "aube/<version> <platform> <arch>"
945        assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
946        let parts: Vec<&str> = ua.split(' ').collect();
947        assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
948        // Platform must be a Node-style token, not Rust's `macos`/`windows`.
949        let platform = parts[1];
950        assert!(
951            matches!(
952                platform,
953                "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
954            ),
955            "platform `{platform}` should follow Node's `process.platform` vocabulary"
956        );
957        // Arch must be a Node-style token, not Rust's `x86_64`/`aarch64`.
958        // Allowlist is the union of mapped outputs (`node_arch`) and the
959        // pass-through tokens that already match Node's vocabulary.
960        let arch = parts[2];
961        assert!(
962            matches!(
963                arch,
964                "x64"
965                    | "arm64"
966                    | "ia32"
967                    | "arm"
968                    | "ppc"
969                    | "ppc64"
970                    | "loong64"
971                    | "mips"
972                    | "riscv64"
973                    | "s390x"
974            ),
975            "arch `{arch}` should follow Node's `process.arch` vocabulary"
976        );
977    }
978}
979
980#[cfg(test)]
981mod jail_tests {
982    use super::*;
983
984    #[test]
985    fn jail_home_uses_full_package_path() {
986        let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
987        let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
988
989        assert_ne!(a, b);
990        assert!(
991            a.file_name()
992                .unwrap()
993                .to_string_lossy()
994                .starts_with("native-")
995        );
996        assert!(
997            b.file_name()
998                .unwrap()
999                .to_string_lossy()
1000                .starts_with("native-")
1001        );
1002    }
1003
1004    #[test]
1005    fn jail_home_cleanup_removes_temp_home() {
1006        let package_dir = std::env::temp_dir()
1007            .join("aube-jail-cleanup-test")
1008            .join(std::process::id().to_string())
1009            .join("node_modules")
1010            .join("native");
1011        let jail = ScriptJail::new(&package_dir);
1012        let home = jail_home(&package_dir);
1013        std::fs::create_dir_all(home.join(".cache")).unwrap();
1014        std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1015
1016        {
1017            let _cleanup = ScriptJailHomeCleanup::new(&jail);
1018        }
1019
1020        assert!(!home.exists());
1021    }
1022
1023    #[test]
1024    fn parent_env_cannot_override_explicit_jail_metadata() {
1025        for key in [
1026            "PATH",
1027            "HOME",
1028            "npm_lifecycle_event",
1029            "npm_package_name",
1030            "npm_package_version",
1031        ] {
1032            assert!(!inherit_jail_env_key(key, &[]));
1033        }
1034        assert!(inherit_jail_env_key("INIT_CWD", &[]));
1035        assert!(inherit_jail_env_key("npm_config_arch", &[]));
1036        assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1037        assert!(inherit_jail_env_key(
1038            "SHARP_DIST_BASE_URL",
1039            &["SHARP_DIST_BASE_URL".to_string()]
1040        ));
1041    }
1042
1043    #[test]
1044    fn jail_env_preserves_script_settings_after_clear() {
1045        let mut cmd = tokio::process::Command::new("node");
1046        let manifest = PackageJson {
1047            name: Some("pkg".to_string()),
1048            version: Some("1.2.3".to_string()),
1049            ..Default::default()
1050        };
1051        let settings = ScriptSettings {
1052            node_options: Some("--conditions=aube".to_string()),
1053            unsafe_perm: Some(false),
1054            shell_emulator: true,
1055            ..Default::default()
1056        };
1057
1058        apply_jail_env(
1059            &mut cmd,
1060            std::ffi::OsStr::new("/bin"),
1061            Path::new("/tmp/aube-jail/home"),
1062            Path::new("/tmp/project"),
1063            &manifest,
1064            "postinstall",
1065            &[],
1066        );
1067        apply_script_settings_env(&mut cmd, &settings);
1068
1069        let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1070        let env = |name: &str| {
1071            envs.iter()
1072                .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1073                .and_then(|(_, val)| *val)
1074                .and_then(|val| val.to_str())
1075        };
1076
1077        assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1078        assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1079        assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1080        assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1081        assert_eq!(env("npm_package_name"), Some("pkg"));
1082        assert_eq!(env("npm_package_version"), Some("1.2.3"));
1083    }
1084}
1085
1086#[cfg(all(test, windows))]
1087mod windows_quote_tests {
1088    use super::shell_quote_arg;
1089
1090    #[test]
1091    fn windows_path_backslash_not_doubled() {
1092        let q = shell_quote_arg(r"C:\Users\me\file.txt");
1093        assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1094    }
1095
1096    #[test]
1097    fn windows_trailing_backslash_doubled_before_close_quote() {
1098        let q = shell_quote_arg(r"C:\path\");
1099        assert_eq!(q, "\"C:\\path\\\\\"");
1100    }
1101
1102    #[test]
1103    fn windows_quote_in_arg_escapes_with_backslash() {
1104        assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1105        assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1106        assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1107    }
1108}