aube_scripts/lib.rs
1//! Lifecycle script runner for aube.
2//!
3//! **Security model**:
4//! - Scripts from the **root package** (the project's own `package.json`)
5//! run by default. They're written by the user, so they're trusted the
6//! same way a user trusts `aube run <script>`.
7//! - Scripts from **installed dependencies** (e.g. `node-gyp` postinstall
8//! from a native module) are SKIPPED by default. A package runs its
9//! lifecycle scripts only if the active [`BuildPolicy`] allows it —
10//! configured via `pnpm.allowBuilds` in `package.json`, `allowBuilds`
11//! in `pnpm-workspace.yaml`, or the escape-hatch
12//! `--dangerously-allow-all-builds` flag.
13//! - `--ignore-scripts` forces everything off, matching pnpm/npm.
14
15pub mod policy;
16
17pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError};
18
19use aube_manifest::PackageJson;
20use std::path::{Path, PathBuf};
21
22/// Settings that affect every package-script shell aube spawns.
23#[derive(Debug, Clone, Default)]
24pub struct ScriptSettings {
25 pub node_options: Option<String>,
26 pub script_shell: Option<PathBuf>,
27 pub unsafe_perm: Option<bool>,
28 pub shell_emulator: bool,
29}
30
31static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
32 std::sync::OnceLock::new();
33
34fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
35 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
36}
37
38/// Replace the process-wide script settings snapshot. CLI commands call
39/// this after resolving `.npmrc` / workspace settings for the active
40/// project.
41pub fn set_script_settings(settings: ScriptSettings) {
42 match script_settings_lock().write() {
43 Ok(mut guard) => *guard = settings,
44 Err(poisoned) => *poisoned.into_inner() = settings,
45 }
46}
47
48fn script_settings() -> ScriptSettings {
49 match script_settings_lock().read() {
50 Ok(guard) => guard.clone(),
51 Err(poisoned) => poisoned.into_inner().clone(),
52 }
53}
54
55/// Prepend `bin_dir` to the current `PATH` using the platform's path
56/// separator (`:` on Unix, `;` on Windows).
57pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
58 let path = std::env::var_os("PATH").unwrap_or_default();
59 let mut entries = vec![bin_dir.to_path_buf()];
60 entries.extend(std::env::split_paths(&path));
61 std::env::join_paths(entries).unwrap_or(path)
62}
63
64/// Spawn a shell command line. On Unix we go through `sh -c`, on
65/// Windows through `cmd.exe /d /s /c` — matching what npm passes in
66/// `@npmcli/run-script`.
67///
68/// On Windows, the script command line is appended with
69/// [`std::os::windows::process::CommandExt::raw_arg`] instead of
70/// the normal `.arg()` path. `.arg()` would run the string through
71/// Rust's `CommandLineToArgvW`-oriented encoder, which wraps it in
72/// `"..."` and escapes interior `"` as `\"` — but `cmd.exe` parses
73/// command lines with a different set of rules and does not
74/// understand `\"`, so a script like
75/// `node -e "require('is-odd')(3)"` arrives mangled. `raw_arg`
76/// hands the command line to `CreateProcessW` verbatim, so we
77/// control the exact bytes cmd.exe sees. We wrap the whole script
78/// in an outer pair of double quotes, which `/s` tells cmd.exe to
79/// strip (just those outer quotes — the rest of the string is
80/// preserved literally). This is the same trick
81/// `@npmcli/run-script` and `node-cross-spawn` use.
82pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
83 let settings = script_settings();
84 #[cfg(unix)]
85 {
86 let mut cmd = tokio::process::Command::new(
87 settings
88 .script_shell
89 .as_deref()
90 .unwrap_or_else(|| Path::new("sh")),
91 );
92 cmd.arg("-c").arg(script_cmd);
93 apply_script_settings_env(&mut cmd, &settings);
94 cmd
95 }
96 #[cfg(windows)]
97 {
98 let mut cmd = tokio::process::Command::new(
99 settings
100 .script_shell
101 .as_deref()
102 .unwrap_or_else(|| Path::new("cmd.exe")),
103 );
104 if settings.script_shell.is_some() {
105 cmd.arg("-c").arg(script_cmd);
106 } else {
107 // `/d` skips AutoRun, `/s` flips the quote-stripping rule
108 // so only the *outer* `"..."` pair is removed, `/c` runs
109 // the command and exits. Build the raw argv tail manually
110 // so cmd.exe sees the original script bytes.
111 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
112 }
113 apply_script_settings_env(&mut cmd, &settings);
114 cmd
115 }
116}
117
118/// Shell-quote one arg for safe splicing into a shell command line.
119///
120/// Used by `aube run <script> -- args`. Args get joined into the
121/// script string, then sh -c or cmd /c reparses the whole thing. If
122/// user arg contains $, backticks, ;, |, &, (, ), etc, the shell
123/// interprets those as metacharacters. That is shell injection.
124/// `aube run echo 'hello; rm -rf ~'` would run two commands. Same
125/// issue npm had pre-2016. Quote each arg so shell treats it as one
126/// literal token.
127///
128/// Unix: wrap in single quotes. sh treats interior of '...' as pure
129/// literal with one exception, embedded single quote. Handle that
130/// with the standard '\'' escape trick: close the single-quoted
131/// string, emit an escaped quote, reopen. Works in every POSIX sh.
132///
133/// Windows cmd.exe: wrap in double quotes. cmd interprets many
134/// metachars even inside double quotes, but CreateProcessW hands the
135/// string to our spawn_shell that uses `/d /s /c "..."`, the outer
136/// quotes get stripped per /s rule and the content runs. Escape
137/// interior " and backslash per CommandLineToArgvW. Full cmd.exe
138/// metachar caret-escaping is a rabbit hole, so this is best-effort,
139/// works for the common cases, matches what node's shell-quote does.
140pub fn shell_quote_arg(arg: &str) -> String {
141 #[cfg(unix)]
142 {
143 let mut out = String::with_capacity(arg.len() + 2);
144 out.push('\'');
145 for ch in arg.chars() {
146 if ch == '\'' {
147 out.push_str("'\\''");
148 } else {
149 out.push(ch);
150 }
151 }
152 out.push('\'');
153 out
154 }
155 #[cfg(windows)]
156 {
157 let mut out = String::with_capacity(arg.len() + 2);
158 out.push('"');
159 let mut backslashes: usize = 0;
160 for ch in arg.chars() {
161 match ch {
162 '\\' => backslashes += 1,
163 '"' => {
164 for _ in 0..backslashes * 2 + 1 {
165 out.push('\\');
166 }
167 out.push('"');
168 backslashes = 0;
169 }
170 // cmd.exe expands %VAR% even inside double quotes.
171 // Outer `/s /c "..."` only strips the outermost
172 // quote pair, the shell still runs env expansion
173 // on the body. Argument like `%COMSPEC%` would
174 // otherwise get replaced with the shell path
175 // before the child saw it. Double the percent so
176 // cmd passes a literal `%` through. Full
177 // caret-escaping of `^ & | < > ( )` is a deeper
178 // rabbit hole, this handles the common injection
179 // vector.
180 '%' => {
181 for _ in 0..backslashes {
182 out.push('\\');
183 }
184 backslashes = 0;
185 out.push_str("%%");
186 }
187 _ => {
188 for _ in 0..backslashes {
189 out.push('\\');
190 }
191 backslashes = 0;
192 out.push(ch);
193 }
194 }
195 }
196 for _ in 0..backslashes * 2 {
197 out.push('\\');
198 }
199 out.push('"');
200 out
201 }
202}
203
204/// Translate child ExitStatus to a parent exit code.
205///
206/// On Unix a signal-killed child has None from .code(). Old code
207/// collapsed that to 1. That loses signal identity: SIGKILL (OOM
208/// killer, exit 137), SIGSEGV (139), Ctrl-C (130) all look like
209/// plain exit 1. CI pipelines watching for 137 to detect OOM cannot
210/// distinguish it from a normal script error anymore. Bash convention
211/// is 128 + signum, match that.
212///
213/// Windows has no signal concept so .code() is always Some, the
214/// fallback 1 is dead code there but keeps the function total.
215pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
216 if let Some(code) = status.code() {
217 return code;
218 }
219 #[cfg(unix)]
220 {
221 use std::os::unix::process::ExitStatusExt;
222 if let Some(sig) = status.signal() {
223 return 128 + sig;
224 }
225 }
226 1
227}
228
229fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
230 // Strip credentials that aube itself owns before we spawn any
231 // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login
232 // token. No transitive postinstall has any business reading it.
233 // NPM_TOKEN and NODE_AUTH_TOKEN stay untouched because release
234 // flows ("npm publish" in a postpublish script) genuinely need
235 // them. Matches what pnpm does today.
236 cmd.env_remove("AUBE_AUTH_TOKEN");
237 if let Some(node_options) = settings.node_options.as_deref() {
238 cmd.env("NODE_OPTIONS", node_options);
239 }
240 if let Some(unsafe_perm) = settings.unsafe_perm {
241 cmd.env(
242 "npm_config_unsafe_perm",
243 if unsafe_perm { "true" } else { "false" },
244 );
245 }
246 if settings.shell_emulator {
247 cmd.env("npm_config_shell_emulator", "true");
248 }
249}
250
251/// Lifecycle hooks that `aube install` runs against the root package's
252/// `scripts` field, in this order: `preinstall` → (dependencies link) →
253/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum LifecycleHook {
256 PreInstall,
257 Install,
258 PostInstall,
259 Prepare,
260}
261
262impl LifecycleHook {
263 pub fn script_name(self) -> &'static str {
264 match self {
265 Self::PreInstall => "preinstall",
266 Self::Install => "install",
267 Self::PostInstall => "postinstall",
268 Self::Prepare => "prepare",
269 }
270 }
271}
272
273/// Dependency lifecycle hooks, in the order aube runs them for each
274/// allowlisted package. `prepare` is intentionally omitted — it's meant
275/// for the root package and git-dep preparation, not installed tarballs.
276pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
277 LifecycleHook::PreInstall,
278 LifecycleHook::Install,
279 LifecycleHook::PostInstall,
280];
281
282/// Holds the real stderr fd saved before `aube` redirects fd 2 to
283/// `/dev/null` under `--silent`. Child processes spawned through
284/// `child_stderr()` get a fresh dup of this fd so their stderr still
285/// reaches the user's terminal — `--silent` only silences aube's own
286/// output, not the scripts / binaries it invokes (matches `pnpm
287/// --loglevel silent`). A value of `-1` means silent mode is off and
288/// children should inherit stderr normally.
289#[cfg(unix)]
290static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
291
292/// Called once by `aube` after it saves + redirects fd 2. Passing
293/// the caller-owned saved fd here means child processes spawned via
294/// `child_stderr()` will write to the real terminal stderr instead of
295/// `/dev/null`.
296#[cfg(unix)]
297pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
298 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
299}
300
301/// Windows has no equivalent fd-based silencing plumbing: aube's
302/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
303/// calls are gated to unix in `aube`. The stub keeps the public
304/// API shape identical so call sites compile unchanged.
305#[cfg(not(unix))]
306pub fn set_saved_stderr_fd(_fd: i32) {}
307
308/// Returns a `Stdio` suitable for a child process's stderr. When silent
309/// mode is active, this dups the saved real-stderr fd so the child
310/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
311/// `Stdio::inherit()`.
312#[cfg(unix)]
313pub fn child_stderr() -> std::process::Stdio {
314 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
315 if fd < 0 {
316 return std::process::Stdio::inherit();
317 }
318 // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
319 // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
320 // duration of main. `BorrowedFd` only borrows, so this does not
321 // transfer ownership.
322 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
323 match borrowed.try_clone_to_owned() {
324 Ok(owned) => std::process::Stdio::from(owned),
325 Err(_) => std::process::Stdio::inherit(),
326 }
327}
328
329#[cfg(not(unix))]
330pub fn child_stderr() -> std::process::Stdio {
331 std::process::Stdio::inherit()
332}
333
334/// Run a single npm-style script line through `sh -c` with the usual
335/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
336/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
337///
338/// `extra_bin_dirs` are prepended to `PATH` in order, *before* the
339/// project-level `.bin`. Dep lifecycle scripts pass the dep's own
340/// sibling `node_modules/.bin/` so transitive binaries (e.g.
341/// `prebuild-install`, `node-gyp`) declared in the dep's
342/// `dependencies` are reachable, optionally followed by aube-owned
343/// tool dirs (e.g. the bootstrapped node-gyp). Root scripts pass
344/// `&[]` — their transitive bins are already hoisted into the
345/// project-level `.bin`.
346///
347/// Inherits stdio from the parent so the user sees script output live.
348/// Returns Err on non-zero exit so install fails fast if a lifecycle
349/// script breaks, matching pnpm.
350pub async fn run_script(
351 script_dir: &Path,
352 project_root: &Path,
353 modules_dir_name: &str,
354 manifest: &PackageJson,
355 script_name: &str,
356 script_cmd: &str,
357 extra_bin_dirs: &[&Path],
358) -> Result<(), Error> {
359 // PATH prepends (most-local-first): `extra_bin_dirs` in caller
360 // order, then the project root's `<modules_dir>/.bin`. For root
361 // scripts `script_dir == project_root` and `extra_bin_dirs` is
362 // empty, which matches the old behavior. `modules_dir_name`
363 // honors pnpm's `modulesDir` setting — defaults to
364 // `"node_modules"` at the call site, but a workspace may have
365 // configured something else.
366 let project_bin = project_root.join(modules_dir_name).join(".bin");
367 let path = std::env::var_os("PATH").unwrap_or_default();
368 let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
369 for dir in extra_bin_dirs {
370 entries.push(dir.to_path_buf());
371 }
372 entries.push(project_bin);
373 entries.extend(std::env::split_paths(&path));
374 let new_path = std::env::join_paths(entries).unwrap_or(path);
375
376 let mut cmd = spawn_shell(script_cmd);
377 cmd.current_dir(script_dir)
378 .stderr(child_stderr())
379 .env("PATH", &new_path)
380 .env("npm_lifecycle_event", script_name);
381
382 // Pass INIT_CWD the way npm/pnpm do — the directory the user
383 // invoked the package manager from, *not* the script's own cwd.
384 // Native-module build tooling (node-gyp, prebuild-install, etc.)
385 // reads INIT_CWD to locate the project root when caching binaries.
386 // Preserve if already set by a parent aube invocation so nested
387 // scripts see the outermost cwd.
388 if std::env::var_os("INIT_CWD").is_none() {
389 cmd.env("INIT_CWD", project_root);
390 }
391
392 if let Some(ref name) = manifest.name {
393 cmd.env("npm_package_name", name);
394 }
395 if let Some(ref version) = manifest.version {
396 cmd.env("npm_package_version", version);
397 }
398
399 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
400 let status = cmd
401 .status()
402 .await
403 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
404
405 if !status.success() {
406 return Err(Error::NonZeroExit {
407 script: script_name.to_string(),
408 code: status.code(),
409 });
410 }
411
412 Ok(())
413}
414
415/// Run a lifecycle hook against the root package, if a script for it is
416/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
417/// `Ok(true)` if it ran successfully.
418///
419/// The caller is responsible for gating on `--ignore-scripts`.
420pub async fn run_root_hook(
421 project_dir: &Path,
422 modules_dir_name: &str,
423 manifest: &PackageJson,
424 hook: LifecycleHook,
425) -> Result<bool, Error> {
426 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
427}
428
429/// Run a named root-package script if it's defined. Used by commands
430/// (pack, publish, version) that need to run lifecycle hooks outside
431/// the install-focused [`LifecycleHook`] enum. Returns `Ok(false)` if
432/// the script isn't defined.
433///
434/// The caller is responsible for gating on `--ignore-scripts`.
435pub async fn run_root_script_by_name(
436 project_dir: &Path,
437 modules_dir_name: &str,
438 manifest: &PackageJson,
439 name: &str,
440) -> Result<bool, Error> {
441 let Some(script_cmd) = manifest.scripts.get(name) else {
442 return Ok(false);
443 };
444 run_script(
445 project_dir,
446 project_dir,
447 modules_dir_name,
448 manifest,
449 name,
450 script_cmd,
451 &[],
452 )
453 .await?;
454 Ok(true)
455}
456
457/// Single source of truth for the implicit `node-gyp rebuild`
458/// fallback: returns `Some("node-gyp rebuild")` when the package ships
459/// a `binding.gyp` at its root AND the manifest leaves both `install`
460/// and `preinstall` empty (either one is the author's explicit
461/// opt-out from the default).
462///
463/// `has_binding_gyp` is passed by the caller so this helper is
464/// agnostic to *how* presence was detected — the install pipeline
465/// stats the materialized package dir, while `aube ignored-builds`
466/// reads the store `PackageIndex` since the package may not be
467/// linked into `node_modules` yet. Both paths must agree on the gate
468/// condition, so they both go through this.
469pub fn implicit_install_script(
470 manifest: &PackageJson,
471 has_binding_gyp: bool,
472) -> Option<&'static str> {
473 if !has_binding_gyp {
474 return None;
475 }
476 if manifest
477 .scripts
478 .contains_key(LifecycleHook::Install.script_name())
479 || manifest
480 .scripts
481 .contains_key(LifecycleHook::PreInstall.script_name())
482 {
483 return None;
484 }
485 Some("node-gyp rebuild")
486}
487
488/// Default `install` command for a materialized dependency directory.
489/// Thin wrapper around [`implicit_install_script`] that supplies
490/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
491pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
492 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
493}
494
495/// True if [`run_dep_hook`] would actually execute something for this
496/// package across any of the dependency lifecycle hooks. Callers use
497/// this to skip fan-out work for packages that have nothing to run —
498/// including the implicit `node-gyp rebuild` default.
499pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
500 if DEP_LIFECYCLE_HOOKS
501 .iter()
502 .any(|h| manifest.scripts.contains_key(h.script_name()))
503 {
504 return true;
505 }
506 default_install_script(package_dir, manifest).is_some()
507}
508
509/// Run a lifecycle hook against an installed dependency's package
510/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
511/// (the actual linked package directory, e.g.
512/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
513/// is the dependency's own `package.json`, *not* the project root's.
514///
515/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
516/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
517/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
518/// is prepended to `PATH` so the dep's postinstall can spawn tools
519/// declared in its own `dependencies` (the transitive-bin case —
520/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
521/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
522/// the same pass.
523///
524/// For the `install` hook specifically, if the manifest leaves both
525/// `install` and `preinstall` empty but the package has a top-level
526/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
527/// node-gyp default that npm and pnpm both honor so native modules
528/// without a prebuilt binary still compile on install.
529///
530/// `tool_bin_dirs` are prepended to `PATH` *after* the dep's own
531/// `.bin` so that aube-bootstrapped tools (e.g. node-gyp) fill the
532/// gap for deps that shell out to them without declaring them as
533/// their own `dependencies`. The dep's local bin still wins if it
534/// shipped its own copy.
535///
536/// The caller is responsible for gating on `BuildPolicy` and
537/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
538pub async fn run_dep_hook(
539 package_dir: &Path,
540 dep_modules_dir: &Path,
541 project_root: &Path,
542 modules_dir_name: &str,
543 manifest: &PackageJson,
544 hook: LifecycleHook,
545 tool_bin_dirs: &[&Path],
546) -> Result<bool, Error> {
547 let name = hook.script_name();
548 let script_cmd: &str = match manifest.scripts.get(name) {
549 Some(s) => s.as_str(),
550 None => match hook {
551 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
552 Some(s) => s,
553 None => return Ok(false),
554 },
555 _ => return Ok(false),
556 },
557 };
558 let dep_bin_dir = dep_modules_dir.join(".bin");
559 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
560 bin_dirs.push(&dep_bin_dir);
561 bin_dirs.extend(tool_bin_dirs.iter().copied());
562 run_script(
563 package_dir,
564 project_root,
565 modules_dir_name,
566 manifest,
567 name,
568 script_cmd,
569 &bin_dirs,
570 )
571 .await?;
572 Ok(true)
573}
574
575#[derive(Debug, thiserror::Error)]
576pub enum Error {
577 #[error("failed to spawn script {0}: {1}")]
578 Spawn(String, String),
579 #[error("script `{script}` exited with code {code:?}")]
580 NonZeroExit { script: String, code: Option<i32> },
581}
582
583#[cfg(all(test, windows))]
584mod windows_quote_tests {
585 use super::shell_quote_arg;
586
587 #[test]
588 fn windows_path_backslash_not_doubled() {
589 let q = shell_quote_arg(r"C:\Users\me\file.txt");
590 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
591 }
592
593 #[test]
594 fn windows_trailing_backslash_doubled_before_close_quote() {
595 let q = shell_quote_arg(r"C:\path\");
596 assert_eq!(q, "\"C:\\path\\\\\"");
597 }
598
599 #[test]
600 fn windows_quote_in_arg_escapes_with_backslash() {
601 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
602 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
603 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
604 }
605}