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