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/// Write `line` plus a newline to the parent's real stderr. Used by
660/// the recursive-run output multiplexer, which pipes child stderr
661/// through aube and re-emits each line with a `<package>: ` prefix —
662/// `eprintln!` writes to fd 2, which `SilentStderrGuard` has redirected
663/// to `/dev/null` under `--silent`, so child stderr would otherwise be
664/// silently swallowed in `--silent --parallel` mode. Routes through the
665/// saved real-stderr fd when silent mode is active, fd 2 otherwise.
666///
667/// `write_all` of a pre-built `<line>\n` buffer issues a single short
668/// write to the kernel; on TTYs and pipes the kernel's `PIPE_BUF`
669/// (= 4096+ on every supported unix) atomicity keeps lines from
670/// concurrent pump tasks intact without explicit locking. The dup
671/// happens per line so we don't share a long-lived `File` handle that
672/// would need its own lock — a duplicate `write` syscall pair is
673/// cheaper than an `Arc<Mutex<File>>` and correct under concurrency.
674#[cfg(unix)]
675pub fn write_line_to_real_stderr(line: &str) {
676    use std::io::Write;
677    let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
678    let fd = if saved >= 0 { saved } else { 2 };
679    // SAFETY: `fd` is either the saved real-stderr fd (kept live by
680    // `SilentStderrGuard` for the duration of main) or fd 2 (always
681    // open). `BorrowedFd` only borrows; ownership stays with the
682    // saved-fd / std-stream side and `try_clone_to_owned` issues a
683    // `dup` so dropping the resulting `File` does not close fd 2 or
684    // the saved fd.
685    let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
686    let Ok(owned) = borrowed.try_clone_to_owned() else {
687        return;
688    };
689    let mut file = std::fs::File::from(owned);
690    let mut buf = String::with_capacity(line.len() + 1);
691    buf.push_str(line);
692    buf.push('\n');
693    let _ = file.write_all(buf.as_bytes());
694}
695
696#[cfg(not(unix))]
697pub fn write_line_to_real_stderr(line: &str) {
698    eprintln!("{line}");
699}
700
701/// Run a single npm-style script line through `sh -c` with the usual
702/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
703/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
704///
705/// `extra_bin_dirs` are prepended to `PATH` in order, *before* the
706/// project-level `.bin`. Dep lifecycle scripts pass the dep's own
707/// sibling `node_modules/.bin/` so transitive binaries (e.g.
708/// `prebuild-install`, `node-gyp`) declared in the dep's
709/// `dependencies` are reachable, optionally followed by aube-owned
710/// tool dirs (e.g. the bootstrapped node-gyp). Root scripts pass
711/// `&[]` — their transitive bins are already hoisted into the
712/// project-level `.bin`.
713///
714/// Inherits stdio from the parent so the user sees script output live.
715/// Returns Err on non-zero exit so install fails fast if a lifecycle
716/// script breaks, matching pnpm.
717#[allow(clippy::too_many_arguments)]
718pub async fn run_script(
719    script_dir: &Path,
720    project_root: &Path,
721    modules_dir_name: &str,
722    manifest: &PackageJson,
723    script_name: &str,
724    script_cmd: &str,
725    extra_bin_dirs: &[&Path],
726    jail: Option<&ScriptJail>,
727) -> Result<(), Error> {
728    // Per-script diag span. Tags the package name (when present) and the
729    // script name so the analyzer can attribute postinstall / preinstall /
730    // build cost to the exact lifecycle entry rather than the aggregate
731    // `dep_lifecycle` phase total.
732    let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
733        .with_meta_fn(|| {
734            let pkg = manifest.name.as_deref().unwrap_or("(root)");
735            format!(
736                r#"{{"pkg":{},"script":{}}}"#,
737                aube_util::diag::jstr(pkg),
738                aube_util::diag::jstr(script_name)
739            )
740        });
741    // PATH prepends (most-local-first): `extra_bin_dirs` in caller
742    // order, then the project root's `<modules_dir>/.bin`. For root
743    // scripts `script_dir == project_root` and `extra_bin_dirs` is
744    // empty, which matches the old behavior. `modules_dir_name`
745    // honors pnpm's `modulesDir` setting — defaults to
746    // `"node_modules"` at the call site, but a workspace may have
747    // configured something else.
748    let project_bin = project_root.join(modules_dir_name).join(".bin");
749    let path = std::env::var_os("PATH").unwrap_or_default();
750    let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
751    for dir in extra_bin_dirs {
752        entries.push(dir.to_path_buf());
753    }
754    entries.push(project_bin);
755    entries.extend(std::env::split_paths(&path));
756    let new_path = std::env::join_paths(entries).unwrap_or(path);
757
758    let settings = script_settings();
759    let jail_home = jail.map(|j| jail_home(&j.package_dir));
760    if let Some(home) = &jail_home {
761        std::fs::create_dir_all(home)
762            .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
763    }
764    let mut cmd = match (jail, jail_home.as_deref()) {
765        (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
766        _ => spawn_shell_with_settings(script_cmd, &settings),
767    };
768    cmd.current_dir(script_dir)
769        .stderr(child_stderr())
770        .env("PATH", &new_path)
771        .env("npm_lifecycle_event", script_name);
772
773    // Pass INIT_CWD the way npm/pnpm do — the directory the user
774    // invoked the package manager from, *not* the script's own cwd.
775    // Native-module build tooling (node-gyp, prebuild-install, etc.)
776    // reads INIT_CWD to locate the project root when caching binaries.
777    // Preserve if already set by a parent aube invocation so nested
778    // scripts see the outermost cwd.
779    if std::env::var_os("INIT_CWD").is_none() {
780        cmd.env("INIT_CWD", project_root);
781    }
782
783    if let Some(ref name) = manifest.name {
784        cmd.env("npm_package_name", name);
785    }
786    if let Some(ref version) = manifest.version {
787        cmd.env("npm_package_version", version);
788    }
789    if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
790        apply_jail_env(
791            &mut cmd,
792            &new_path,
793            home,
794            project_root,
795            manifest,
796            script_name,
797            &jail.env,
798        );
799        apply_script_settings_env(&mut cmd, &settings);
800    }
801
802    tracing::debug!("lifecycle: {script_name} → {script_cmd}");
803    let status = cmd
804        .status()
805        .await
806        .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
807
808    if !status.success() {
809        return Err(Error::NonZeroExit {
810            script: script_name.to_string(),
811            code: status.code(),
812        });
813    }
814
815    Ok(())
816}
817
818/// Run a lifecycle hook against the root package, if a script for it is
819/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
820/// `Ok(true)` if it ran successfully.
821///
822/// The caller is responsible for gating on `--ignore-scripts`.
823pub async fn run_root_hook(
824    project_dir: &Path,
825    modules_dir_name: &str,
826    manifest: &PackageJson,
827    hook: LifecycleHook,
828) -> Result<bool, Error> {
829    run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
830}
831
832/// Run a named root-package script if it's defined. Used by commands
833/// (pack, publish, version) that need to run lifecycle hooks outside
834/// the install-focused [`LifecycleHook`] enum. Returns `Ok(false)` if
835/// the script isn't defined.
836///
837/// The caller is responsible for gating on `--ignore-scripts`.
838pub async fn run_root_script_by_name(
839    project_dir: &Path,
840    modules_dir_name: &str,
841    manifest: &PackageJson,
842    name: &str,
843) -> Result<bool, Error> {
844    let Some(script_cmd) = manifest.scripts.get(name) else {
845        return Ok(false);
846    };
847    run_script(
848        project_dir,
849        project_dir,
850        modules_dir_name,
851        manifest,
852        name,
853        script_cmd,
854        &[],
855        None,
856    )
857    .await?;
858    Ok(true)
859}
860
861/// Single source of truth for the implicit `node-gyp rebuild`
862/// fallback: returns `Some("node-gyp rebuild")` when the package ships
863/// a `binding.gyp` at its root AND the manifest leaves both `install`
864/// and `preinstall` empty (either one is the author's explicit
865/// opt-out from the default).
866///
867/// `has_binding_gyp` is passed by the caller so this helper is
868/// agnostic to *how* presence was detected — the install pipeline
869/// stats the materialized package dir, while `aube ignored-builds`
870/// reads the store `PackageIndex` since the package may not be
871/// linked into `node_modules` yet. Both paths must agree on the gate
872/// condition, so they both go through this.
873pub fn implicit_install_script(
874    manifest: &PackageJson,
875    has_binding_gyp: bool,
876) -> Option<&'static str> {
877    if !has_binding_gyp {
878        return None;
879    }
880    if manifest
881        .scripts
882        .contains_key(LifecycleHook::Install.script_name())
883        || manifest
884            .scripts
885            .contains_key(LifecycleHook::PreInstall.script_name())
886    {
887        return None;
888    }
889    Some("node-gyp rebuild")
890}
891
892/// Default `install` command for a materialized dependency directory.
893/// Thin wrapper around [`implicit_install_script`] that supplies
894/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
895pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
896    implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
897}
898
899/// True if [`run_dep_hook`] would actually execute something for this
900/// package across any of the dependency lifecycle hooks. Callers use
901/// this to skip fan-out work for packages that have nothing to run —
902/// including the implicit `node-gyp rebuild` default.
903pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
904    if DEP_LIFECYCLE_HOOKS
905        .iter()
906        .any(|h| manifest.scripts.contains_key(h.script_name()))
907    {
908        return true;
909    }
910    default_install_script(package_dir, manifest).is_some()
911}
912
913/// Run a lifecycle hook against an installed dependency's package
914/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
915/// (the actual linked package directory, e.g.
916/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
917/// is the dependency's own `package.json`, *not* the project root's.
918///
919/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
920/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
921/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
922/// is prepended to `PATH` so the dep's postinstall can spawn tools
923/// declared in its own `dependencies` (the transitive-bin case —
924/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
925/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
926/// the same pass.
927///
928/// For the `install` hook specifically, if the manifest leaves both
929/// `install` and `preinstall` empty but the package has a top-level
930/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
931/// node-gyp default that npm and pnpm both honor so native modules
932/// without a prebuilt binary still compile on install.
933///
934/// `tool_bin_dirs` are prepended to `PATH` *after* the dep's own
935/// `.bin` so that aube-bootstrapped tools (e.g. node-gyp) fill the
936/// gap for deps that shell out to them without declaring them as
937/// their own `dependencies`. The dep's local bin still wins if it
938/// shipped its own copy.
939///
940/// The caller is responsible for gating on `BuildPolicy` and
941/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
942#[allow(clippy::too_many_arguments)]
943pub async fn run_dep_hook(
944    package_dir: &Path,
945    dep_modules_dir: &Path,
946    project_root: &Path,
947    modules_dir_name: &str,
948    manifest: &PackageJson,
949    hook: LifecycleHook,
950    tool_bin_dirs: &[&Path],
951    jail: Option<&ScriptJail>,
952) -> Result<bool, Error> {
953    let name = hook.script_name();
954    let script_cmd: &str = match manifest.scripts.get(name) {
955        Some(s) => s.as_str(),
956        None => match hook {
957            LifecycleHook::Install => match default_install_script(package_dir, manifest) {
958                Some(s) => s,
959                None => return Ok(false),
960            },
961            _ => return Ok(false),
962        },
963    };
964    let dep_bin_dir = dep_modules_dir.join(".bin");
965    let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
966    bin_dirs.push(&dep_bin_dir);
967    bin_dirs.extend(tool_bin_dirs.iter().copied());
968    run_script(
969        package_dir,
970        project_root,
971        modules_dir_name,
972        manifest,
973        name,
974        script_cmd,
975        &bin_dirs,
976        jail,
977    )
978    .await?;
979    Ok(true)
980}
981
982#[derive(Debug, thiserror::Error, miette::Diagnostic)]
983pub enum Error {
984    #[error("failed to spawn script {0}: {1}")]
985    #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
986    Spawn(String, String),
987    #[error("script `{script}` exited with code {code:?}")]
988    #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
989    NonZeroExit { script: String, code: Option<i32> },
990}
991
992#[cfg(test)]
993mod user_agent_tests {
994    use super::*;
995
996    #[test]
997    fn user_agent_uses_node_style_platform_and_arch() {
998        let ua = aube_user_agent();
999        // Format: "aube/<version> <platform> <arch>"
1000        assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1001        let parts: Vec<&str> = ua.split(' ').collect();
1002        assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1003        // Platform must be a Node-style token, not Rust's `macos`/`windows`.
1004        let platform = parts[1];
1005        assert!(
1006            matches!(
1007                platform,
1008                "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1009            ),
1010            "platform `{platform}` should follow Node's `process.platform` vocabulary"
1011        );
1012        // Arch must be a Node-style token, not Rust's `x86_64`/`aarch64`.
1013        // Allowlist is the union of mapped outputs (`node_arch`) and the
1014        // pass-through tokens that already match Node's vocabulary.
1015        let arch = parts[2];
1016        assert!(
1017            matches!(
1018                arch,
1019                "x64"
1020                    | "arm64"
1021                    | "ia32"
1022                    | "arm"
1023                    | "ppc"
1024                    | "ppc64"
1025                    | "loong64"
1026                    | "mips"
1027                    | "riscv64"
1028                    | "s390x"
1029            ),
1030            "arch `{arch}` should follow Node's `process.arch` vocabulary"
1031        );
1032    }
1033}
1034
1035#[cfg(test)]
1036mod jail_tests {
1037    use super::*;
1038
1039    #[test]
1040    fn jail_home_uses_full_package_path() {
1041        let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1042        let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1043
1044        assert_ne!(a, b);
1045        assert!(
1046            a.file_name()
1047                .unwrap()
1048                .to_string_lossy()
1049                .starts_with("native-")
1050        );
1051        assert!(
1052            b.file_name()
1053                .unwrap()
1054                .to_string_lossy()
1055                .starts_with("native-")
1056        );
1057    }
1058
1059    #[test]
1060    fn jail_home_cleanup_removes_temp_home() {
1061        let package_dir = std::env::temp_dir()
1062            .join("aube-jail-cleanup-test")
1063            .join(std::process::id().to_string())
1064            .join("node_modules")
1065            .join("native");
1066        let jail = ScriptJail::new(&package_dir);
1067        let home = jail_home(&package_dir);
1068        std::fs::create_dir_all(home.join(".cache")).unwrap();
1069        std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1070
1071        {
1072            let _cleanup = ScriptJailHomeCleanup::new(&jail);
1073        }
1074
1075        assert!(!home.exists());
1076    }
1077
1078    #[test]
1079    fn parent_env_cannot_override_explicit_jail_metadata() {
1080        for key in [
1081            "PATH",
1082            "HOME",
1083            "npm_lifecycle_event",
1084            "npm_package_name",
1085            "npm_package_version",
1086        ] {
1087            assert!(!inherit_jail_env_key(key, &[]));
1088        }
1089        assert!(inherit_jail_env_key("INIT_CWD", &[]));
1090        assert!(inherit_jail_env_key("npm_config_arch", &[]));
1091        assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1092        assert!(inherit_jail_env_key(
1093            "SHARP_DIST_BASE_URL",
1094            &["SHARP_DIST_BASE_URL".to_string()]
1095        ));
1096    }
1097
1098    #[test]
1099    fn jail_env_preserves_script_settings_after_clear() {
1100        let mut cmd = tokio::process::Command::new("node");
1101        let manifest = PackageJson {
1102            name: Some("pkg".to_string()),
1103            version: Some("1.2.3".to_string()),
1104            ..Default::default()
1105        };
1106        let settings = ScriptSettings {
1107            node_options: Some("--conditions=aube".to_string()),
1108            unsafe_perm: Some(false),
1109            shell_emulator: true,
1110            ..Default::default()
1111        };
1112
1113        apply_jail_env(
1114            &mut cmd,
1115            std::ffi::OsStr::new("/bin"),
1116            Path::new("/tmp/aube-jail/home"),
1117            Path::new("/tmp/project"),
1118            &manifest,
1119            "postinstall",
1120            &[],
1121        );
1122        apply_script_settings_env(&mut cmd, &settings);
1123
1124        let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1125        let env = |name: &str| {
1126            envs.iter()
1127                .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1128                .and_then(|(_, val)| *val)
1129                .and_then(|val| val.to_str())
1130        };
1131
1132        assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1133        assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1134        assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1135        assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1136        assert_eq!(env("npm_package_name"), Some("pkg"));
1137        assert_eq!(env("npm_package_version"), Some("1.2.3"));
1138    }
1139}
1140
1141#[cfg(all(test, windows))]
1142mod windows_quote_tests {
1143    use super::shell_quote_arg;
1144
1145    #[test]
1146    fn windows_path_backslash_not_doubled() {
1147        let q = shell_quote_arg(r"C:\Users\me\file.txt");
1148        assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1149    }
1150
1151    #[test]
1152    fn windows_trailing_backslash_doubled_before_close_quote() {
1153        let q = shell_quote_arg(r"C:\path\");
1154        assert_eq!(q, "\"C:\\path\\\\\"");
1155    }
1156
1157    #[test]
1158    fn windows_quote_in_arg_escapes_with_backslash() {
1159        assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1160        assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1161        assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1162    }
1163}