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 content_sniff;
16pub mod policy;
17
18#[cfg(target_os = "linux")]
19mod linux_jail;
20
21#[cfg(windows)]
22mod windows_job;
23
24pub use content_sniff::{Suspicion, SuspicionKind, sniff_lifecycle};
25pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
26
27use aube_manifest::PackageJson;
28use std::collections::hash_map::DefaultHasher;
29use std::hash::{Hash, Hasher};
30use std::path::{Path, PathBuf};
31
32/// Settings that affect every package-script shell aube spawns.
33#[derive(Debug, Clone, Default)]
34pub struct ScriptSettings {
35 pub node_options: Option<String>,
36 pub script_shell: Option<PathBuf>,
37 pub unsafe_perm: Option<bool>,
38 pub shell_emulator: bool,
39 /// Directory of the project's resolved Node runtime, prepended to
40 /// PATH after the project `.bin` so the switched node beats the
41 /// system one while project-local binaries still win. `None` when
42 /// no runtime switching is active.
43 pub node_bin_dir: Option<PathBuf>,
44 /// The resolved node executable, exported as `npm_node_execpath` /
45 /// `NODE` (npm parity) for every script.
46 pub node_exe: Option<PathBuf>,
47 /// The top-level package-manager command, exported as `npm_command`
48 /// (npm/pnpm parity): `"run-script"` for `aube run`, `"install"`
49 /// for install lifecycle hooks, `"rebuild"`, `"pack"`, etc. `None`
50 /// leaves `npm_command` unset.
51 pub command: Option<String>,
52 /// Path to a runnable `node-gyp` stand-in, exported as
53 /// `npm_config_node_gyp` (npm/pnpm parity). npm/pnpm point this at
54 /// their bundled `node-gyp/bin/node-gyp.js`; aube bootstraps node-gyp
55 /// lazily, so this is a tiny shim that resolves (and bootstraps on
56 /// first use) the real node-gyp and forwards argv. `None` leaves
57 /// `npm_config_node_gyp` unset.
58 pub node_gyp_js: Option<PathBuf>,
59 /// The proxy aube resolved for its own registry traffic, re-exported
60 /// to lifecycle scripts as the standard `HTTPS_PROXY` / `HTTP_PROXY`
61 /// / `NO_PROXY` env vars (plus `NODE_USE_ENV_PROXY=1`) so a script's
62 /// own `fetch()` / `http` calls honor the same proxy. aube reaches
63 /// scripts as `npm_config_proxy`, which Node ignores — only these
64 /// vars work. `None` on each leaves the corresponding var unset; the
65 /// proxy block is skipped entirely when both `http_proxy` and
66 /// `https_proxy` are `None`.
67 pub http_proxy: Option<String>,
68 pub https_proxy: Option<String>,
69 pub no_proxy: Option<String>,
70}
71
72/// Native build jail applied to dependency lifecycle scripts.
73#[derive(Debug, Clone)]
74pub struct ScriptJail {
75 pub package_dir: PathBuf,
76 pub env: Vec<String>,
77 pub read_paths: Vec<PathBuf>,
78 pub write_paths: Vec<PathBuf>,
79 pub network: bool,
80}
81
82impl ScriptJail {
83 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
84 Self {
85 package_dir: package_dir.into(),
86 env: Vec::new(),
87 read_paths: Vec::new(),
88 write_paths: Vec::new(),
89 network: false,
90 }
91 }
92
93 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
94 self.env = env.into_iter().collect();
95 self
96 }
97
98 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
99 self.read_paths = paths.into_iter().collect();
100 self
101 }
102
103 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
104 self.write_paths = paths.into_iter().collect();
105 self
106 }
107
108 pub fn with_network(mut self, network: bool) -> Self {
109 self.network = network;
110 self
111 }
112}
113
114pub struct ScriptJailHomeCleanup {
115 path: PathBuf,
116}
117
118impl ScriptJailHomeCleanup {
119 pub fn new(jail: &ScriptJail) -> Self {
120 Self {
121 path: jail_home(&jail.package_dir),
122 }
123 }
124}
125
126impl Drop for ScriptJailHomeCleanup {
127 fn drop(&mut self) {
128 if self.path.exists()
129 && let Err(err) = std::fs::remove_dir_all(&self.path)
130 {
131 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
132 }
133 }
134}
135
136static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
137 std::sync::OnceLock::new();
138
139fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
140 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
141}
142
143/// Replace the process-wide script settings snapshot. CLI commands call
144/// this after resolving `.npmrc` / workspace settings for the active
145/// project.
146pub fn set_script_settings(settings: ScriptSettings) {
147 match script_settings_lock().write() {
148 Ok(mut guard) => *guard = settings,
149 Err(poisoned) => *poisoned.into_inner() = settings,
150 }
151}
152
153fn script_settings() -> ScriptSettings {
154 match script_settings_lock().read() {
155 Ok(guard) => guard.clone(),
156 Err(poisoned) => poisoned.into_inner().clone(),
157 }
158}
159
160/// Prepend `bin_dir` to the current `PATH` using the platform's path
161/// separator (`:` on Unix, `;` on Windows).
162pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
163 prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf()))
164}
165
166/// [`prepend_path`] for multiple directories, prepended in order.
167pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString {
168 let path = std::env::var_os("PATH").unwrap_or_default();
169 let mut entries: Vec<PathBuf> = bin_dirs.to_vec();
170 entries.extend(std::env::split_paths(&path));
171 std::env::join_paths(entries).unwrap_or(path)
172}
173
174/// Spawn a shell command line. On Unix we go through `sh -c`, on
175/// Windows through `cmd.exe /d /s /c` — matching what npm passes in
176/// `@npmcli/run-script`.
177///
178/// On Windows, the script command line is appended with
179/// [`std::os::windows::process::CommandExt::raw_arg`] instead of
180/// the normal `.arg()` path. `.arg()` would run the string through
181/// Rust's `CommandLineToArgvW`-oriented encoder, which wraps it in
182/// `"..."` and escapes interior `"` as `\"` — but `cmd.exe` parses
183/// command lines with a different set of rules and does not
184/// understand `\"`, so a script like
185/// `node -e "require('is-odd')(3)"` arrives mangled. `raw_arg`
186/// hands the command line to `CreateProcessW` verbatim, so we
187/// control the exact bytes cmd.exe sees. We wrap the whole script
188/// in an outer pair of double quotes, which `/s` tells cmd.exe to
189/// strip (just those outer quotes — the rest of the string is
190/// preserved literally). This is the same trick
191/// `@npmcli/run-script` and `node-cross-spawn` use.
192pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
193 let settings = script_settings();
194 spawn_shell_with_settings(script_cmd, &settings)
195}
196
197fn spawn_shell_with_settings(
198 script_cmd: &str,
199 settings: &ScriptSettings,
200) -> tokio::process::Command {
201 #[cfg(unix)]
202 let mut cmd = {
203 let mut cmd = tokio::process::Command::new(
204 settings
205 .script_shell
206 .as_deref()
207 .unwrap_or_else(|| Path::new("sh")),
208 );
209 cmd.arg("-c").arg(script_cmd);
210 cmd
211 };
212 #[cfg(windows)]
213 let mut cmd = {
214 let mut cmd = tokio::process::Command::new(
215 settings
216 .script_shell
217 .as_deref()
218 .unwrap_or_else(|| Path::new("cmd.exe")),
219 );
220 if settings.script_shell.is_some() {
221 cmd.arg("-c").arg(script_cmd);
222 } else {
223 // `/d` skips AutoRun, `/s` flips the quote-stripping rule
224 // so only the *outer* `"..."` pair is removed, `/c` runs
225 // the command and exits. Build the raw argv tail manually
226 // so cmd.exe sees the original script bytes.
227 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
228 }
229 cmd
230 };
231 apply_script_settings_env(&mut cmd, settings);
232 // Aborting the `JoinSet` that drives the parallel lifecycle pass
233 // drops the spawned `Child`, which without `kill_on_drop` would
234 // leave the shell running detached (Discussion #654). On Windows
235 // that's only half the fix — `TerminateProcess` on `cmd.exe`
236 // doesn't reach grandchildren like `node-gyp` → `MSBuild` → `node`;
237 // [`run_command_killing_descendants`] also assigns the shell to a
238 // `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` job object to reap the
239 // whole tree.
240 cmd.kill_on_drop(true);
241 cmd
242}
243
244#[cfg(target_os = "macos")]
245fn sbpl_escape(s: &str) -> String {
246 s.replace('\\', "\\\\").replace('"', "\\\"")
247}
248
249#[cfg(target_os = "macos")]
250fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
251 let path = sbpl_escape(&path.to_string_lossy());
252 let rule = format!("(allow file-write* (subpath \"{path}\"))");
253 if !rules.iter().any(|existing| existing == &rule) {
254 rules.push(rule);
255 }
256}
257
258#[cfg(target_os = "macos")]
259fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
260 let mut rules = vec![
261 "(version 1)".to_string(),
262 "(allow default)".to_string(),
263 "(allow network* (local unix))".to_string(),
264 "(deny file-write*)".to_string(),
265 ];
266 if !jail.network {
267 rules.insert(2, "(deny network*)".to_string());
268 }
269
270 for path in [
271 Path::new("/tmp"),
272 Path::new("/private/tmp"),
273 Path::new("/dev"),
274 ] {
275 push_write_rule(&mut rules, path);
276 }
277 for path in [&jail.package_dir, home] {
278 push_write_rule(&mut rules, path);
279 }
280 for path in &jail.write_paths {
281 push_write_rule(&mut rules, path);
282 }
283 for path in [&jail.package_dir, home] {
284 if let Ok(canonical) = path.canonicalize() {
285 push_write_rule(&mut rules, &canonical);
286 }
287 }
288 for path in &jail.write_paths {
289 if let Ok(canonical) = path.canonicalize() {
290 push_write_rule(&mut rules, &canonical);
291 }
292 }
293 rules.join("\n")
294}
295
296#[cfg(target_os = "macos")]
297fn spawn_jailed_shell(
298 script_cmd: &str,
299 settings: &ScriptSettings,
300 jail: &ScriptJail,
301 home: &Path,
302) -> tokio::process::Command {
303 let shell = settings
304 .script_shell
305 .as_deref()
306 .unwrap_or_else(|| Path::new("sh"));
307 let profile = jail_profile(jail, home);
308 let mut cmd = tokio::process::Command::new("sandbox-exec");
309 cmd.arg("-p")
310 .arg(profile)
311 .arg("--")
312 .arg(shell)
313 .arg("-c")
314 .arg(script_cmd);
315 apply_script_settings_env(&mut cmd, settings);
316 // Matches the unjailed path — see `spawn_shell_with_settings`.
317 cmd.kill_on_drop(true);
318 cmd
319}
320
321#[cfg(target_os = "linux")]
322fn spawn_jailed_shell(
323 script_cmd: &str,
324 settings: &ScriptSettings,
325 jail: &ScriptJail,
326 home: &Path,
327) -> tokio::process::Command {
328 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
329 let jail = jail.clone();
330 let home = home.to_path_buf();
331 unsafe {
332 cmd.pre_exec(move || {
333 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
334 if !jail.network {
335 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
336 }
337 Ok(())
338 });
339 }
340 cmd
341}
342
343#[cfg(not(any(target_os = "linux", target_os = "macos")))]
344fn spawn_jailed_shell(
345 script_cmd: &str,
346 settings: &ScriptSettings,
347 _jail: &ScriptJail,
348 _home: &Path,
349) -> tokio::process::Command {
350 spawn_shell_with_settings(script_cmd, settings)
351}
352
353/// Shell-quote one arg for safe splicing into a shell command line.
354///
355/// Used by `aube run <script> -- args`. Args get joined into the
356/// script string, then sh -c or cmd /c reparses the whole thing. If
357/// user arg contains $, backticks, ;, |, &, (, ), etc, the shell
358/// interprets those as metacharacters. That is shell injection.
359/// `aube run echo 'hello; rm -rf ~'` would run two commands. Same
360/// issue npm had pre-2016. Quote each arg so shell treats it as one
361/// literal token.
362///
363/// Unix: wrap in single quotes. sh treats interior of '...' as pure
364/// literal with one exception, embedded single quote. Handle that
365/// with the standard '\'' escape trick: close the single-quoted
366/// string, emit an escaped quote, reopen. Works in every POSIX sh.
367///
368/// Windows cmd.exe: wrap in double quotes. cmd interprets many
369/// metachars even inside double quotes, but CreateProcessW hands the
370/// string to our spawn_shell that uses `/d /s /c "..."`, the outer
371/// quotes get stripped per /s rule and the content runs. Escape
372/// interior " and backslash per CommandLineToArgvW. Full cmd.exe
373/// metachar caret-escaping is a rabbit hole, so this is best-effort,
374/// works for the common cases, matches what node's shell-quote does.
375pub fn shell_quote_arg(arg: &str) -> String {
376 #[cfg(unix)]
377 {
378 let mut out = String::with_capacity(arg.len() + 2);
379 out.push('\'');
380 for ch in arg.chars() {
381 if ch == '\'' {
382 out.push_str("'\\''");
383 } else {
384 out.push(ch);
385 }
386 }
387 out.push('\'');
388 out
389 }
390 #[cfg(windows)]
391 {
392 let mut out = String::with_capacity(arg.len() + 2);
393 out.push('"');
394 let mut backslashes: usize = 0;
395 for ch in arg.chars() {
396 match ch {
397 '\\' => backslashes += 1,
398 '"' => {
399 for _ in 0..backslashes * 2 + 1 {
400 out.push('\\');
401 }
402 out.push('"');
403 backslashes = 0;
404 }
405 // cmd.exe expands %VAR% even inside double quotes.
406 // Outer `/s /c "..."` only strips the outermost
407 // quote pair, the shell still runs env expansion
408 // on the body. Argument like `%COMSPEC%` would
409 // otherwise get replaced with the shell path
410 // before the child saw it. Double the percent so
411 // cmd passes a literal `%` through. Full
412 // caret-escaping of `^ & | < > ( )` is a deeper
413 // rabbit hole, this handles the common injection
414 // vector.
415 '%' => {
416 for _ in 0..backslashes {
417 out.push('\\');
418 }
419 backslashes = 0;
420 out.push_str("%%");
421 }
422 _ => {
423 for _ in 0..backslashes {
424 out.push('\\');
425 }
426 backslashes = 0;
427 out.push(ch);
428 }
429 }
430 }
431 for _ in 0..backslashes * 2 {
432 out.push('\\');
433 }
434 out.push('"');
435 out
436 }
437}
438
439/// Translate child ExitStatus to a parent exit code.
440///
441/// On Unix a signal-killed child has None from .code(). Old code
442/// collapsed that to 1. That loses signal identity: SIGKILL (OOM
443/// killer, exit 137), SIGSEGV (139), Ctrl-C (130) all look like
444/// plain exit 1. CI pipelines watching for 137 to detect OOM cannot
445/// distinguish it from a normal script error anymore. Bash convention
446/// is 128 + signum, match that.
447///
448/// Windows has no signal concept so .code() is always Some, the
449/// fallback 1 is dead code there but keeps the function total.
450pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
451 if let Some(code) = status.code() {
452 return code;
453 }
454 #[cfg(unix)]
455 {
456 use std::os::unix::process::ExitStatusExt;
457 if let Some(sig) = status.signal() {
458 return 128 + sig;
459 }
460 }
461 1
462}
463
464/// User agent string exported to lifecycle scripts as
465/// `npm_config_user_agent`. Mirrors pnpm's format
466/// (`<name>/<version> <os> <arch>`) so dep build scripts that sniff
467/// the env var to detect the running PM (e.g. `husky`,
468/// `unrs-resolver`) recognize aube without falling back to npm-mode.
469/// OS/arch use Node's `process.platform` / `process.arch` vocabulary
470/// (`darwin`/`linux`/`win32`, `x64`/`arm64`), not Rust's native
471/// `std::env::consts::{OS,ARCH}` values, so tools that parse the full
472/// UA string identify the platform the same way npm/yarn/pnpm do.
473pub fn aube_user_agent() -> String {
474 format!(
475 "{} {} {}",
476 aube_util::embedder().user_agent,
477 node_platform(),
478 node_arch(),
479 )
480}
481
482fn node_platform() -> &'static str {
483 match std::env::consts::OS {
484 "macos" => "darwin",
485 "windows" => "win32",
486 other => other,
487 }
488}
489
490fn node_arch() -> &'static str {
491 // Mappings from Rust's `std::env::consts::ARCH` to Node's
492 // `process.arch`. Common arches first; the rare ones at the bottom
493 // exist so the test below stays a real guarantee on every host
494 // Rust ships, not just x64/arm64. Pass-through covers `arm`,
495 // `mips`, `riscv64`, `s390x` — those tokens match between the two
496 // vocabularies.
497 match std::env::consts::ARCH {
498 "x86_64" => "x64",
499 "aarch64" => "arm64",
500 "x86" => "ia32",
501 "powerpc" => "ppc",
502 "powerpc64" => "ppc64",
503 "loongarch64" => "loong64",
504 other => other,
505 }
506}
507
508fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
509 // Strip credentials that aube itself owns before we spawn any
510 // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login
511 // token. No transitive postinstall has any business reading it.
512 // NPM_TOKEN and NODE_AUTH_TOKEN stay untouched because release
513 // flows ("npm publish" in a postpublish script) genuinely need
514 // them. Matches what pnpm does today.
515 cmd.env_remove("AUBE_AUTH_TOKEN");
516 // pnpm parity: every lifecycle script gets `npm_config_user_agent`
517 // so dep postinstalls can detect the running PM. Set here (not at
518 // spawn time) so it flows through both the jailed and the
519 // non-jailed paths.
520 cmd.env("npm_config_user_agent", aube_user_agent());
521 // `npm_execpath`: the package-manager binary that drove the script.
522 // Tools (and pnpm's own `$npm_execpath run …` postinstalls) read it
523 // to re-invoke the *same* PM. `current_exe()` is the aube binary;
524 // ignore the rare resolution failure rather than abort the script.
525 // Reused below for `AUBE_NODE_GYP_EXE` (same binary), so resolve once.
526 let aube_exe = std::env::current_exe().ok();
527 if let Some(exe) = aube_exe.as_deref() {
528 cmd.env("npm_execpath", exe);
529 }
530 // `npm_node_execpath` / `NODE`: the node binary scripts should use
531 // — the switched runtime's node, or the ambient `node` on PATH.
532 // Set here (not at spawn) so it survives the jail's `env_clear`.
533 if let Some(node_exe) = settings.node_exe.as_deref() {
534 cmd.env("npm_node_execpath", node_exe).env("NODE", node_exe);
535 }
536 // `npm_command`: the top-level PM command (run-script / install / …).
537 if let Some(command) = settings.command.as_deref() {
538 cmd.env("npm_command", command);
539 }
540 // `npm_config_node_gyp`: path to a runnable node-gyp. npm/pnpm bundle
541 // node-gyp and point this at its `bin/node-gyp.js`; aube hands out a
542 // lazy shim that resolves the bootstrapped node-gyp on first use. The
543 // shim trampolines back into aube via `AUBE_NODE_GYP_EXE`, so always
544 // stamp the running aube — it must be a real aube that implements
545 // `__node-gyp-bootstrap`, never an inherited/user-set value (which
546 // could be stale or wrong). `aube run` stamps the same value at its
547 // own spawn site; keeping both unconditional holds the two paths in
548 // lockstep. `AUBE_NODE_GYP_PROJECT_DIR` is optional — the shim falls
549 // back to the script's cwd.
550 if let Some(node_gyp_js) = settings.node_gyp_js.as_deref() {
551 cmd.env("npm_config_node_gyp", node_gyp_js);
552 if let Some(exe) = aube_exe.as_deref() {
553 cmd.env("AUBE_NODE_GYP_EXE", exe);
554 }
555 }
556 if let Some(node_options) = settings.node_options.as_deref() {
557 cmd.env("NODE_OPTIONS", node_options);
558 }
559 if let Some(unsafe_perm) = settings.unsafe_perm {
560 cmd.env(
561 "npm_config_unsafe_perm",
562 if unsafe_perm { "true" } else { "false" },
563 );
564 }
565 if settings.shell_emulator {
566 cmd.env("npm_config_shell_emulator", "true");
567 }
568 // Proxy passthrough. aube already resolved a proxy for its own
569 // registry traffic and forwards it to scripts as `npm_config_proxy`,
570 // but Node's built-in `fetch`/`http`/`https` only read the standard
571 // `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` vars, and only when
572 // `NODE_USE_ENV_PROXY=1` is set (Node 24+). Stamp all four so a
573 // dependency's install script hits the same proxy aube does. A proxy
574 // URL is not a secret, so this is safe to leak past the jail. Skip
575 // the whole block when no proxy is configured — no reason to flip
576 // Node's experimental env-proxy flag (which warns on Node 24) for a
577 // direct connection. Use the plain env var, never
578 // `NODE_OPTIONS=--use-env-proxy`: Node < 24 rejects that unknown
579 // flag and refuses to start, breaking installs; the plain var is
580 // silently ignored there instead. Set after `env_clear` (like `NODE`
581 // above) so it survives the jail.
582 if settings.http_proxy.is_some() || settings.https_proxy.is_some() {
583 if let Some(https) = settings.https_proxy.as_deref() {
584 cmd.env("HTTPS_PROXY", https);
585 }
586 if let Some(http) = settings.http_proxy.as_deref() {
587 cmd.env("HTTP_PROXY", http);
588 }
589 if let Some(no_proxy) = settings.no_proxy.as_deref() {
590 cmd.env("NO_PROXY", no_proxy);
591 }
592 cmd.env("NODE_USE_ENV_PROXY", "1");
593 }
594}
595
596/// Apply the manifest-derived `npm_package_*` env (name, version,
597/// absolute `npm_package_json` path, and the deep-flattened
598/// `engines`/`config`/`bin`) plus `npm_lifecycle_script` (the raw
599/// script body) to a lifecycle or `aube run` command. Call last so the
600/// values land *after* any jail `env_clear`. `script_dir` is the
601/// directory the script runs in, whose `package.json` is the manifest
602/// being executed; `lifecycle_script` is the raw script body exported
603/// as `npm_lifecycle_script`.
604///
605/// pnpm rebuilds the `npm_package_*` namespace per package: it drops
606/// every inherited `npm_package_*` key and stamps only the running
607/// manifest's allowlist. We mirror that — scrub first, then re-stamp —
608/// so a script never sees a parent/sibling package's fields (verified
609/// against pnpm 11.5). Without the scrub, non-allowlisted inherited
610/// keys (e.g. an outer `npm run`'s `npm_package_description`) would
611/// leak through on the unjailed path and break allowlist parity. On
612/// the jailed path the prior `env_clear` already dropped them, so the
613/// scrub is a harmless no-op there.
614pub fn apply_npm_manifest_env(
615 cmd: &mut tokio::process::Command,
616 manifest: &PackageJson,
617 script_dir: &Path,
618 lifecycle_script: &str,
619) {
620 for (key, _) in std::env::vars_os() {
621 if key.to_str().is_some_and(|k| k.starts_with("npm_package_")) {
622 cmd.env_remove(&key);
623 }
624 }
625 cmd.env("npm_lifecycle_script", lifecycle_script);
626 cmd.env("npm_package_json", script_dir.join("package.json"));
627 for (key, value) in manifest.npm_package_env() {
628 cmd.env(key, value);
629 }
630}
631
632fn safe_jail_env_key(key: &str) -> bool {
633 const EXACT: &[&str] = &[
634 "PATH",
635 "HOME",
636 "TERM",
637 "LANG",
638 "LC_ALL",
639 "INIT_CWD",
640 "npm_lifecycle_event",
641 "npm_package_name",
642 "npm_package_version",
643 ];
644 if EXACT.contains(&key) {
645 return true;
646 }
647 let lower = key.to_ascii_lowercase();
648 if lower.contains("token")
649 || lower.contains("auth")
650 || lower.contains("password")
651 || lower.contains("credential")
652 || lower.contains("secret")
653 {
654 return false;
655 }
656 key.starts_with("npm_config_")
657}
658
659fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
660 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
661 && !matches!(
662 key,
663 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
664 )
665}
666
667fn jail_home(package_dir: &Path) -> PathBuf {
668 let mut hasher = DefaultHasher::new();
669 package_dir.hash(&mut hasher);
670 let hash = hasher.finish();
671 let name = package_dir
672 .file_name()
673 .and_then(|s| s.to_str())
674 .unwrap_or("package")
675 .chars()
676 .map(|c| {
677 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
678 c
679 } else {
680 '_'
681 }
682 })
683 .collect::<String>();
684 std::env::temp_dir()
685 .join("aube-jail")
686 .join(std::process::id().to_string())
687 .join(format!("{name}-{hash:016x}"))
688}
689
690fn apply_jail_env(
691 cmd: &mut tokio::process::Command,
692 path_env: &std::ffi::OsStr,
693 home: &Path,
694 project_root: &Path,
695 manifest: &PackageJson,
696 script_name: &str,
697 extra_env: &[String],
698) {
699 cmd.env_clear();
700 cmd.env("PATH", path_env)
701 .env("HOME", home)
702 .env("TMPDIR", home)
703 .env("TMP", home)
704 .env("TEMP", home)
705 .env("npm_lifecycle_event", script_name);
706 if std::env::var_os("INIT_CWD").is_none() {
707 cmd.env("INIT_CWD", project_root);
708 }
709 if let Some(ref name) = manifest.name {
710 cmd.env("npm_package_name", name);
711 }
712 if let Some(ref version) = manifest.version {
713 cmd.env("npm_package_version", version);
714 }
715 for (key, val) in std::env::vars_os() {
716 let Some(key_str) = key.to_str() else {
717 continue;
718 };
719 if inherit_jail_env_key(key_str, extra_env) {
720 cmd.env(key, val);
721 }
722 }
723}
724
725/// Lifecycle hooks that `aube install` runs against the root package's
726/// `scripts` field, in this order: `preinstall` → (dependencies link) →
727/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub enum LifecycleHook {
730 PreInstall,
731 Install,
732 PostInstall,
733 Prepare,
734}
735
736impl LifecycleHook {
737 pub fn script_name(self) -> &'static str {
738 match self {
739 Self::PreInstall => "preinstall",
740 Self::Install => "install",
741 Self::PostInstall => "postinstall",
742 Self::Prepare => "prepare",
743 }
744 }
745}
746
747/// Dependency lifecycle hooks, in the order aube runs them for each
748/// allowlisted package. `prepare` is intentionally omitted — it's meant
749/// for the root package and git-dep preparation, not installed tarballs.
750pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
751 LifecycleHook::PreInstall,
752 LifecycleHook::Install,
753 LifecycleHook::PostInstall,
754];
755
756/// Holds the real stderr fd saved before `aube` redirects fd 2 to
757/// `/dev/null` under `--silent`. Child processes spawned through
758/// `child_stderr()` get a fresh dup of this fd so their stderr still
759/// reaches the user's terminal — `--silent` only silences aube's own
760/// output, not the scripts / binaries it invokes (matches `pnpm
761/// --loglevel silent`). A value of `-1` means silent mode is off and
762/// children should inherit stderr normally.
763#[cfg(unix)]
764static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
765
766/// Called once by `aube` after it saves + redirects fd 2. Passing
767/// the caller-owned saved fd here means child processes spawned via
768/// `child_stderr()` will write to the real terminal stderr instead of
769/// `/dev/null`.
770#[cfg(unix)]
771pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
772 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
773}
774
775/// Windows has no equivalent fd-based silencing plumbing: aube's
776/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
777/// calls are gated to unix in `aube`. The stub keeps the public
778/// API shape identical so call sites compile unchanged.
779#[cfg(not(unix))]
780pub fn set_saved_stderr_fd(_fd: i32) {}
781
782/// Returns a `Stdio` suitable for a child process's stderr. When silent
783/// mode is active, this dups the saved real-stderr fd so the child
784/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
785/// `Stdio::inherit()`.
786#[cfg(unix)]
787pub fn child_stderr() -> std::process::Stdio {
788 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
789 if fd < 0 {
790 return std::process::Stdio::inherit();
791 }
792 // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
793 // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
794 // duration of main. `BorrowedFd` only borrows, so this does not
795 // transfer ownership.
796 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
797 match borrowed.try_clone_to_owned() {
798 Ok(owned) => std::process::Stdio::from(owned),
799 Err(_) => std::process::Stdio::inherit(),
800 }
801}
802
803#[cfg(not(unix))]
804pub fn child_stderr() -> std::process::Stdio {
805 std::process::Stdio::inherit()
806}
807
808/// Write `line` plus a newline to the parent's real stderr. Used by
809/// the recursive-run output multiplexer, which pipes child stderr
810/// through aube and re-emits each line with a `<package>: ` prefix —
811/// `eprintln!` writes to fd 2, which `SilentStderrGuard` has redirected
812/// to `/dev/null` under `--silent`, so child stderr would otherwise be
813/// silently swallowed in `--silent --parallel` mode. Routes through the
814/// saved real-stderr fd when silent mode is active, fd 2 otherwise.
815///
816/// `write_all` of a pre-built `<line>\n` buffer issues a single short
817/// write to the kernel; on TTYs and pipes the kernel's `PIPE_BUF`
818/// (= 4096+ on every supported unix) atomicity keeps lines from
819/// concurrent pump tasks intact without explicit locking. The dup
820/// happens per line so we don't share a long-lived `File` handle that
821/// would need its own lock — a duplicate `write` syscall pair is
822/// cheaper than an `Arc<Mutex<File>>` and correct under concurrency.
823#[cfg(unix)]
824pub fn write_line_to_real_stderr(line: &str) {
825 use std::io::Write;
826 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
827 let fd = if saved >= 0 { saved } else { 2 };
828 // SAFETY: `fd` is either the saved real-stderr fd (kept live by
829 // `SilentStderrGuard` for the duration of main) or fd 2 (always
830 // open). `BorrowedFd` only borrows; ownership stays with the
831 // saved-fd / std-stream side and `try_clone_to_owned` issues a
832 // `dup` so dropping the resulting `File` does not close fd 2 or
833 // the saved fd.
834 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
835 let Ok(owned) = borrowed.try_clone_to_owned() else {
836 return;
837 };
838 let mut file = std::fs::File::from(owned);
839 let mut buf = String::with_capacity(line.len() + 1);
840 buf.push_str(line);
841 buf.push('\n');
842 let _ = file.write_all(buf.as_bytes());
843}
844
845#[cfg(not(unix))]
846pub fn write_line_to_real_stderr(line: &str) {
847 eprintln!("{line}");
848}
849
850/// Spawn `cmd`, wait for it, and on Windows attach the shell to a
851/// kill-on-job-close job object so an aborted lifecycle script reaps
852/// its full descendant tree instead of leaving orphans behind.
853///
854/// `kill_on_drop(true)` on the parent `Command` (set by
855/// [`spawn_shell_with_settings`]) covers `TerminateProcess` /
856/// `SIGKILL` on the direct shell. That alone is enough on Unix
857/// because most build tooling handles the parent dying — and the
858/// shell itself is the foreground process for the subscript pipeline.
859/// On Windows the shell's grandchildren (`node-gyp` → `MSBuild` →
860/// `node`) are *not* part of the shell's job by default, so killing
861/// the shell leaves them running detached. Discussion #654 is the
862/// in-the-wild bug: `aube add --global` failed, aube exited, and
863/// node/MSBuild kept writing to the console.
864///
865/// We mitigate by spawning, then assigning the child process handle
866/// to a job created with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The
867/// `_job` binding's `Drop` (called when this future returns, panics,
868/// or is aborted) closes the last job handle, and the kernel kills
869/// every assigned process — including everything the shell has
870/// spawned by that point. There is a microscopic race between spawn
871/// and `AssignProcessToJobObject`, but the shell does not have time
872/// to spawn anything in that window; the `tokio::process::Child`
873/// returns control to us synchronously after `CreateProcessW`
874/// returns.
875///
876/// Job-object failures are fail-open: restricted Windows environments
877/// (nested-job parents, container policy, handle quota) can refuse
878/// either `CreateJobObjectW` or `AssignProcessToJobObject`. In those
879/// cases we surface a `WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE`
880/// warning and run the script anyway — degrading to the
881/// `kill_on_drop`-only path that aube used before this fix. Failing
882/// closed would block lifecycle scripts entirely on those hosts,
883/// which is a worse regression than the orphaning we're trying to
884/// avoid.
885async fn run_command_killing_descendants(
886 mut cmd: tokio::process::Command,
887 script_name: &str,
888) -> Result<std::process::ExitStatus, Error> {
889 let mut child = cmd
890 .spawn()
891 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
892 #[cfg(windows)]
893 let _job = match windows_job::JobObject::new() {
894 Ok(job) => {
895 // raw_handle() returns None only if the child has already
896 // been reaped, which can't happen between spawn() and the
897 // very next line.
898 if let Some(handle) = child.raw_handle()
899 && let Err(err) = job.assign(handle)
900 {
901 // Realistic causes: parent job created without
902 // JOB_OBJECT_LIMIT_BREAKAWAY_OK (pre-Win8 nested-job
903 // restrictions, enterprise policy), or the shell
904 // already exited. In either case the kill-tree
905 // guarantee is gone — log loud enough that CI logs
906 // pick it up.
907 tracing::warn!(
908 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
909 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
910 grandchildren may be orphaned if the script is aborted"
911 );
912 }
913 Some(job)
914 }
915 Err(err) => {
916 tracing::warn!(
917 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
918 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
919 running without orphan-reaping — grandchildren may leak if aborted"
920 );
921 None
922 }
923 };
924 child
925 .wait()
926 .await
927 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
928}
929
930/// Run a single npm-style script line through `sh -c` with the usual
931/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
932/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
933///
934/// `extra_bin_dirs` are prepended to `PATH` in order, *before* the
935/// project-level `.bin`. Dep lifecycle scripts pass the dep's own
936/// sibling `node_modules/.bin/` so transitive binaries (e.g.
937/// `prebuild-install`, `node-gyp`) declared in the dep's
938/// `dependencies` are reachable, optionally followed by aube-owned
939/// tool dirs (e.g. the bootstrapped node-gyp). Root scripts pass
940/// `&[]` — their transitive bins are already hoisted into the
941/// project-level `.bin`.
942///
943/// Inherits stdio from the parent so the user sees script output live.
944/// Returns Err on non-zero exit so install fails fast if a lifecycle
945/// script breaks, matching pnpm.
946#[allow(clippy::too_many_arguments)]
947pub async fn run_script(
948 script_dir: &Path,
949 project_root: &Path,
950 modules_dir_name: &str,
951 manifest: &PackageJson,
952 script_name: &str,
953 script_cmd: &str,
954 extra_bin_dirs: &[&Path],
955 jail: Option<&ScriptJail>,
956) -> Result<(), Error> {
957 // Per-script diag span. Tags the package name (when present) and the
958 // script name so the analyzer can attribute postinstall / preinstall /
959 // build cost to the exact lifecycle entry rather than the aggregate
960 // `dep_lifecycle` phase total.
961 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
962 .with_meta_fn(|| {
963 let pkg = manifest.name.as_deref().unwrap_or("(root)");
964 format!(
965 r#"{{"pkg":{},"script":{}}}"#,
966 aube_util::diag::jstr(pkg),
967 aube_util::diag::jstr(script_name)
968 )
969 });
970 // PATH prepends (most-local-first): `extra_bin_dirs` in caller
971 // order, then the project root's `<modules_dir>/.bin`. For root
972 // scripts `script_dir == project_root` and `extra_bin_dirs` is
973 // empty, which matches the old behavior. `modules_dir_name`
974 // honors pnpm's `modulesDir` setting — defaults to
975 // `"node_modules"` at the call site, but a workspace may have
976 // configured something else.
977 let project_bin = project_root.join(modules_dir_name).join(".bin");
978 let settings = script_settings();
979 let path = std::env::var_os("PATH").unwrap_or_default();
980 let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 2);
981 for dir in extra_bin_dirs {
982 entries.push(dir.to_path_buf());
983 }
984 entries.push(project_bin);
985 // The switched Node runtime sits between project bins and the
986 // inherited PATH: scripts spawning `node` (directly or via
987 // `#!/usr/bin/env node`) get the project's pinned version, while
988 // anything installed into `.bin` still wins.
989 if let Some(dir) = &settings.node_bin_dir {
990 entries.push(dir.clone());
991 }
992 entries.extend(std::env::split_paths(&path));
993 let new_path = std::env::join_paths(entries).unwrap_or(path);
994 let jail_home = jail.map(|j| jail_home(&j.package_dir));
995 if let Some(home) = &jail_home {
996 std::fs::create_dir_all(home)
997 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
998 }
999 let mut cmd = match (jail, jail_home.as_deref()) {
1000 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
1001 _ => spawn_shell_with_settings(script_cmd, &settings),
1002 };
1003 cmd.current_dir(script_dir)
1004 .stderr(child_stderr())
1005 .env("PATH", &new_path)
1006 .env("npm_lifecycle_event", script_name);
1007
1008 // Pass INIT_CWD the way npm/pnpm do — the directory the user
1009 // invoked the package manager from, *not* the script's own cwd.
1010 // Native-module build tooling (node-gyp, prebuild-install, etc.)
1011 // reads INIT_CWD to locate the project root when caching binaries.
1012 // Preserve if already set by a parent aube invocation so nested
1013 // scripts see the outermost cwd.
1014 if std::env::var_os("INIT_CWD").is_none() {
1015 cmd.env("INIT_CWD", project_root);
1016 }
1017
1018 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
1019 apply_jail_env(
1020 &mut cmd,
1021 &new_path,
1022 home,
1023 project_root,
1024 manifest,
1025 script_name,
1026 &jail.env,
1027 );
1028 apply_script_settings_env(&mut cmd, &settings);
1029 }
1030
1031 // npm-compat manifest env, applied last so it survives the jail's
1032 // `env_clear`: name/version/json plus the deep-flattened
1033 // engines/config/bin, and the raw script body (`npm_lifecycle_script`).
1034 apply_npm_manifest_env(&mut cmd, manifest, script_dir, script_cmd);
1035
1036 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
1037 let status = run_command_killing_descendants(cmd, script_name).await?;
1038
1039 if !status.success() {
1040 return Err(Error::NonZeroExit {
1041 script: script_name.to_string(),
1042 code: status.code(),
1043 });
1044 }
1045
1046 Ok(())
1047}
1048
1049/// Run a lifecycle hook against the root package, if a script for it is
1050/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
1051/// `Ok(true)` if it ran successfully.
1052///
1053/// The caller is responsible for gating on `--ignore-scripts`.
1054pub async fn run_root_hook(
1055 project_dir: &Path,
1056 modules_dir_name: &str,
1057 manifest: &PackageJson,
1058 hook: LifecycleHook,
1059) -> Result<bool, Error> {
1060 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
1061}
1062
1063/// Run a named root-package script if it's defined. Used by commands
1064/// (pack, publish, version) that need to run lifecycle hooks outside
1065/// the install-focused [`LifecycleHook`] enum. Returns `Ok(false)` if
1066/// the script isn't defined.
1067///
1068/// The caller is responsible for gating on `--ignore-scripts`.
1069pub async fn run_root_script_by_name(
1070 project_dir: &Path,
1071 modules_dir_name: &str,
1072 manifest: &PackageJson,
1073 name: &str,
1074) -> Result<bool, Error> {
1075 let Some(script_cmd) = manifest.scripts.get(name) else {
1076 return Ok(false);
1077 };
1078 run_script(
1079 project_dir,
1080 project_dir,
1081 modules_dir_name,
1082 manifest,
1083 name,
1084 script_cmd,
1085 &[],
1086 None,
1087 )
1088 .await?;
1089 Ok(true)
1090}
1091
1092/// Single source of truth for the implicit `node-gyp rebuild`
1093/// fallback: returns `Some("node-gyp rebuild")` when the package ships
1094/// a `binding.gyp` at its root AND the manifest leaves both `install`
1095/// and `preinstall` empty (either one is the author's explicit
1096/// opt-out from the default).
1097///
1098/// `has_binding_gyp` is passed by the caller so this helper is
1099/// agnostic to *how* presence was detected — the install pipeline
1100/// stats the materialized package dir, while `aube ignored-builds`
1101/// reads the store `PackageIndex` since the package may not be
1102/// linked into `node_modules` yet. Both paths must agree on the gate
1103/// condition, so they both go through this.
1104pub fn implicit_install_script(
1105 manifest: &PackageJson,
1106 has_binding_gyp: bool,
1107) -> Option<&'static str> {
1108 if !has_binding_gyp {
1109 return None;
1110 }
1111 if manifest
1112 .scripts
1113 .contains_key(LifecycleHook::Install.script_name())
1114 || manifest
1115 .scripts
1116 .contains_key(LifecycleHook::PreInstall.script_name())
1117 {
1118 return None;
1119 }
1120 Some("node-gyp rebuild")
1121}
1122
1123/// Default `install` command for a materialized dependency directory.
1124/// Thin wrapper around [`implicit_install_script`] that supplies
1125/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
1126pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
1127 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
1128}
1129
1130/// True if [`run_dep_hook`] would actually execute something for this
1131/// package across any of the dependency lifecycle hooks. Callers use
1132/// this to skip fan-out work for packages that have nothing to run —
1133/// including the implicit `node-gyp rebuild` default.
1134pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
1135 if DEP_LIFECYCLE_HOOKS
1136 .iter()
1137 .any(|h| manifest.scripts.contains_key(h.script_name()))
1138 {
1139 return true;
1140 }
1141 default_install_script(package_dir, manifest).is_some()
1142}
1143
1144/// Run a lifecycle hook against an installed dependency's package
1145/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
1146/// (the actual linked package directory, e.g.
1147/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
1148/// is the dependency's own `package.json`, *not* the project root's.
1149///
1150/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
1151/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
1152/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
1153/// is prepended to `PATH` so the dep's postinstall can spawn tools
1154/// declared in its own `dependencies` (the transitive-bin case —
1155/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
1156/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
1157/// the same pass.
1158///
1159/// For the `install` hook specifically, if the manifest leaves both
1160/// `install` and `preinstall` empty but the package has a top-level
1161/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
1162/// node-gyp default that npm and pnpm both honor so native modules
1163/// without a prebuilt binary still compile on install.
1164///
1165/// `tool_bin_dirs` are prepended to `PATH` *after* the dep's own
1166/// `.bin` so that aube-bootstrapped tools (e.g. node-gyp) fill the
1167/// gap for deps that shell out to them without declaring them as
1168/// their own `dependencies`. The dep's local bin still wins if it
1169/// shipped its own copy.
1170///
1171/// The caller is responsible for gating on `BuildPolicy` and
1172/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
1173#[allow(clippy::too_many_arguments)]
1174pub async fn run_dep_hook(
1175 package_dir: &Path,
1176 dep_modules_dir: &Path,
1177 project_root: &Path,
1178 modules_dir_name: &str,
1179 manifest: &PackageJson,
1180 hook: LifecycleHook,
1181 tool_bin_dirs: &[&Path],
1182 jail: Option<&ScriptJail>,
1183) -> Result<bool, Error> {
1184 let name = hook.script_name();
1185 let script_cmd: &str = match manifest.scripts.get(name) {
1186 Some(s) => s.as_str(),
1187 None => match hook {
1188 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1189 Some(s) => s,
1190 None => return Ok(false),
1191 },
1192 _ => return Ok(false),
1193 },
1194 };
1195 let dep_bin_dir = dep_modules_dir.join(".bin");
1196 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1197 bin_dirs.push(&dep_bin_dir);
1198 bin_dirs.extend(tool_bin_dirs.iter().copied());
1199 run_script(
1200 package_dir,
1201 project_root,
1202 modules_dir_name,
1203 manifest,
1204 name,
1205 script_cmd,
1206 &bin_dirs,
1207 jail,
1208 )
1209 .await?;
1210 Ok(true)
1211}
1212
1213#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1214pub enum Error {
1215 #[error("failed to spawn script {0}: {1}")]
1216 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1217 Spawn(String, String),
1218 #[error("script `{script}` exited with code {code:?}")]
1219 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1220 NonZeroExit { script: String, code: Option<i32> },
1221}
1222
1223#[cfg(test)]
1224mod user_agent_tests {
1225 use super::*;
1226
1227 #[test]
1228 fn user_agent_uses_node_style_platform_and_arch() {
1229 let ua = aube_user_agent();
1230 // Format: "aube/<version> <platform> <arch>"
1231 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1232 let parts: Vec<&str> = ua.split(' ').collect();
1233 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1234 // Platform must be a Node-style token, not Rust's `macos`/`windows`.
1235 let platform = parts[1];
1236 assert!(
1237 matches!(
1238 platform,
1239 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1240 ),
1241 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1242 );
1243 // Arch must be a Node-style token, not Rust's `x86_64`/`aarch64`.
1244 // Allowlist is the union of mapped outputs (`node_arch`) and the
1245 // pass-through tokens that already match Node's vocabulary.
1246 let arch = parts[2];
1247 assert!(
1248 matches!(
1249 arch,
1250 "x64"
1251 | "arm64"
1252 | "ia32"
1253 | "arm"
1254 | "ppc"
1255 | "ppc64"
1256 | "loong64"
1257 | "mips"
1258 | "riscv64"
1259 | "s390x"
1260 ),
1261 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1262 );
1263 }
1264}
1265
1266#[cfg(test)]
1267mod jail_tests {
1268 use super::*;
1269
1270 #[test]
1271 fn jail_home_uses_full_package_path() {
1272 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1273 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1274
1275 assert_ne!(a, b);
1276 assert!(
1277 a.file_name()
1278 .unwrap()
1279 .to_string_lossy()
1280 .starts_with("native-")
1281 );
1282 assert!(
1283 b.file_name()
1284 .unwrap()
1285 .to_string_lossy()
1286 .starts_with("native-")
1287 );
1288 }
1289
1290 #[test]
1291 fn jail_home_cleanup_removes_temp_home() {
1292 let package_dir = std::env::temp_dir()
1293 .join("aube-jail-cleanup-test")
1294 .join(std::process::id().to_string())
1295 .join("node_modules")
1296 .join("native");
1297 let jail = ScriptJail::new(&package_dir);
1298 let home = jail_home(&package_dir);
1299 std::fs::create_dir_all(home.join(".cache")).unwrap();
1300 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1301
1302 {
1303 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1304 }
1305
1306 assert!(!home.exists());
1307 }
1308
1309 #[test]
1310 fn parent_env_cannot_override_explicit_jail_metadata() {
1311 for key in [
1312 "PATH",
1313 "HOME",
1314 "npm_lifecycle_event",
1315 "npm_package_name",
1316 "npm_package_version",
1317 ] {
1318 assert!(!inherit_jail_env_key(key, &[]));
1319 }
1320 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1321 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1322 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1323 assert!(inherit_jail_env_key(
1324 "SHARP_DIST_BASE_URL",
1325 &["SHARP_DIST_BASE_URL".to_string()]
1326 ));
1327 }
1328
1329 #[test]
1330 fn jail_env_preserves_script_settings_after_clear() {
1331 let mut cmd = tokio::process::Command::new("node");
1332 let manifest = PackageJson {
1333 name: Some("pkg".to_string()),
1334 version: Some("1.2.3".to_string()),
1335 ..Default::default()
1336 };
1337 let settings = ScriptSettings {
1338 node_options: Some("--conditions=aube".to_string()),
1339 unsafe_perm: Some(false),
1340 shell_emulator: true,
1341 ..Default::default()
1342 };
1343
1344 apply_jail_env(
1345 &mut cmd,
1346 std::ffi::OsStr::new("/bin"),
1347 Path::new("/tmp/aube-jail/home"),
1348 Path::new("/tmp/project"),
1349 &manifest,
1350 "postinstall",
1351 &[],
1352 );
1353 apply_script_settings_env(&mut cmd, &settings);
1354
1355 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1356 let env = |name: &str| {
1357 envs.iter()
1358 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1359 .and_then(|(_, val)| *val)
1360 .and_then(|val| val.to_str())
1361 };
1362
1363 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1364 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1365 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1366 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1367 assert_eq!(env("npm_package_name"), Some("pkg"));
1368 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1369 }
1370
1371 fn proxy_env(settings: ScriptSettings) -> impl Fn(&str) -> Option<String> {
1372 let mut cmd = tokio::process::Command::new("node");
1373 apply_script_settings_env(&mut cmd, &settings);
1374 let envs: Vec<_> = cmd
1375 .as_std()
1376 .get_envs()
1377 .map(|(k, v)| {
1378 (
1379 k.to_string_lossy().into_owned(),
1380 v.map(|v| v.to_string_lossy().into_owned()),
1381 )
1382 })
1383 .collect();
1384 move |name: &str| {
1385 envs.iter()
1386 .find(|(k, _)| k == name)
1387 .and_then(|(_, v)| v.clone())
1388 }
1389 }
1390
1391 #[test]
1392 fn proxy_vars_stamped_when_proxy_configured() {
1393 let env = proxy_env(ScriptSettings {
1394 https_proxy: Some("http://proxy.example:8080".to_string()),
1395 http_proxy: Some("http://proxy.example:8080".to_string()),
1396 no_proxy: Some("localhost,127.0.0.1".to_string()),
1397 ..Default::default()
1398 });
1399 assert_eq!(
1400 env("HTTPS_PROXY").as_deref(),
1401 Some("http://proxy.example:8080")
1402 );
1403 assert_eq!(
1404 env("HTTP_PROXY").as_deref(),
1405 Some("http://proxy.example:8080")
1406 );
1407 assert_eq!(env("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
1408 // Node ignores the proxy vars unless this flag is set (Node 24+);
1409 // it must be the plain env var, not `--use-env-proxy`.
1410 assert_eq!(env("NODE_USE_ENV_PROXY").as_deref(), Some("1"));
1411 }
1412
1413 #[test]
1414 fn proxy_block_skipped_when_no_proxy_configured() {
1415 // With no proxy resolved, none of the passthrough vars — and
1416 // crucially not the experimental `NODE_USE_ENV_PROXY` flag —
1417 // should be stamped onto the script env.
1418 let env = proxy_env(ScriptSettings::default());
1419 assert_eq!(env("HTTPS_PROXY"), None);
1420 assert_eq!(env("HTTP_PROXY"), None);
1421 assert_eq!(env("NO_PROXY"), None);
1422 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1423 }
1424
1425 #[test]
1426 fn no_proxy_alone_does_not_trigger_passthrough() {
1427 // `NO_PROXY` without an actual proxy URL is meaningless, and we
1428 // must not flip Node's env-proxy flag for a direct connection.
1429 let env = proxy_env(ScriptSettings {
1430 no_proxy: Some("example.com".to_string()),
1431 ..Default::default()
1432 });
1433 assert_eq!(env("NO_PROXY"), None);
1434 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1435 }
1436}
1437
1438#[cfg(all(test, windows))]
1439mod windows_quote_tests {
1440 use super::shell_quote_arg;
1441
1442 #[test]
1443 fn windows_path_backslash_not_doubled() {
1444 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1445 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1446 }
1447
1448 #[test]
1449 fn windows_trailing_backslash_doubled_before_close_quote() {
1450 let q = shell_quote_arg(r"C:\path\");
1451 assert_eq!(q, "\"C:\\path\\\\\"");
1452 }
1453
1454 #[test]
1455 fn windows_quote_in_arg_escapes_with_backslash() {
1456 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1457 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1458 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1459 }
1460}
1461
1462// Regression test for Discussion #654: aborting the lifecycle JoinSet
1463// after a failed `aube add --global` left node-gyp / MSBuild / node
1464// running orphaned on Windows because `TerminateProcess` on the cmd.exe
1465// shell does not propagate to its descendants. The Job Object the
1466// spawn helper now attaches the shell to must reap the entire process
1467// tree when the parent future is dropped.
1468#[cfg(all(test, windows))]
1469mod windows_job_object_tests {
1470 use super::*;
1471 use std::time::{Duration, Instant};
1472 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1473 use windows_sys::Win32::System::Threading::{
1474 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1475 };
1476
1477 fn is_process_alive(pid: u32) -> bool {
1478 // SAFETY: documented entry points; we close any handle we
1479 // successfully obtain. `OpenProcess` returns NULL once the
1480 // pid has been reaped or never existed.
1481 unsafe {
1482 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1483 if handle.is_null() {
1484 return false;
1485 }
1486 let mut code: u32 = 0;
1487 let ok = GetExitCodeProcess(handle, &mut code);
1488 CloseHandle(handle);
1489 ok != 0 && code == STILL_ACTIVE as u32
1490 }
1491 }
1492
1493 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1494 let start = Instant::now();
1495 while !check() {
1496 if start.elapsed() > timeout {
1497 return false;
1498 }
1499 tokio::time::sleep(Duration::from_millis(75)).await;
1500 }
1501 true
1502 }
1503
1504 #[tokio::test]
1505 async fn aborting_script_kills_grandchildren() {
1506 // Unique pid-file path per test run so concurrent test
1507 // executions don't stomp each other. `tempfile` is not a
1508 // dep of this crate; std::env::temp_dir + nanos is enough.
1509 let nanos = std::time::SystemTime::now()
1510 .duration_since(std::time::UNIX_EPOCH)
1511 .unwrap_or_default()
1512 .as_nanos();
1513 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1514 // Background a hidden powershell that writes its own PID
1515 // and then sleeps long enough that the test will fail if it
1516 // isn't reaped. `start /b` detaches the powershell from the
1517 // cmd.exe shell — exactly the orphaned-grandchild shape that
1518 // node-gyp / MSBuild produce in Discussion #654. The trailing
1519 // `ping` keeps the shell itself alive for ~8s so the test
1520 // can race a liveness check against the running grandchild
1521 // before aborting the parent future.
1522 let script = format!(
1523 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1524 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1525 & ping -n 10 127.0.0.1 >nul",
1526 pid_file.display()
1527 );
1528 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1529 let task = tokio::spawn(async move {
1530 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1531 });
1532
1533 let appeared = wait_until(
1534 || {
1535 std::fs::read_to_string(&pid_file)
1536 .ok()
1537 .and_then(|pid| pid.trim().parse::<u32>().ok())
1538 .is_some()
1539 },
1540 Duration::from_secs(20),
1541 )
1542 .await;
1543 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1544 let pid: u32 = std::fs::read_to_string(&pid_file)
1545 .expect("read pid file")
1546 .trim()
1547 .parse()
1548 .expect("pid file was parseable before reading");
1549 assert!(
1550 is_process_alive(pid),
1551 "grandchild pid {pid} not alive immediately after writing pid file"
1552 );
1553
1554 // Drop the future mid-`child.wait().await`. The `_job` local
1555 // in `run_command_killing_descendants` drops with it, which
1556 // closes the last handle and fires `KILL_ON_JOB_CLOSE` —
1557 // killing both the shell *and* the detached powershell.
1558 task.abort();
1559 let _ = task.await;
1560
1561 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1562 let _ = std::fs::remove_file(&pid_file);
1563 assert!(
1564 reaped,
1565 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1566 );
1567 }
1568}