1pub 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#[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 pub node_bin_dir: Option<PathBuf>,
42 pub node_program: Option<PathBuf>,
46 pub node_execpath: Option<PathBuf>,
50 pub extra_env: Vec<(std::ffi::OsString, std::ffi::OsString)>,
56 pub command: Option<String>,
61 pub node_gyp_js: Option<PathBuf>,
68 pub http_proxy: Option<String>,
77 pub https_proxy: Option<String>,
78 pub no_proxy: Option<String>,
79}
80
81#[derive(Debug, Clone)]
83pub struct ScriptJail {
84 pub package_dir: PathBuf,
85 pub env: Vec<String>,
86 pub read_paths: Vec<PathBuf>,
87 pub write_paths: Vec<PathBuf>,
88 pub network: bool,
89}
90
91impl ScriptJail {
92 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
93 Self {
94 package_dir: package_dir.into(),
95 env: Vec::new(),
96 read_paths: Vec::new(),
97 write_paths: Vec::new(),
98 network: false,
99 }
100 }
101
102 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
103 self.env = env.into_iter().collect();
104 self
105 }
106
107 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
108 self.read_paths = paths.into_iter().collect();
109 self
110 }
111
112 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
113 self.write_paths = paths.into_iter().collect();
114 self
115 }
116
117 pub fn with_network(mut self, network: bool) -> Self {
118 self.network = network;
119 self
120 }
121}
122
123pub struct ScriptJailHomeCleanup {
124 path: PathBuf,
125}
126
127impl ScriptJailHomeCleanup {
128 pub fn new(jail: &ScriptJail) -> Self {
129 Self {
130 path: jail_home(&jail.package_dir),
131 }
132 }
133}
134
135impl Drop for ScriptJailHomeCleanup {
136 fn drop(&mut self) {
137 if self.path.exists()
138 && let Err(err) = std::fs::remove_dir_all(&self.path)
139 {
140 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
141 }
142 }
143}
144
145#[derive(Debug, Clone, Default)]
146struct ScriptSettingsState {
147 settings: ScriptSettings,
148 node_bin_dir_precedes_project_bins: bool,
149}
150
151static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettingsState>> =
152 std::sync::OnceLock::new();
153
154type ScriptSettingsSlot = std::sync::Arc<std::sync::RwLock<ScriptSettingsState>>;
155
156tokio::task_local! {
157 static INSTALL_SCRIPT_SETTINGS: ScriptSettingsSlot;
158}
159
160pub async fn scope<F: std::future::Future>(future: F) -> F::Output {
162 INSTALL_SCRIPT_SETTINGS
163 .scope(
164 std::sync::Arc::new(std::sync::RwLock::new(ScriptSettingsState::default())),
165 future,
166 )
167 .await
168}
169
170pub fn scope_current<F: std::future::Future>(
172 future: F,
173) -> impl std::future::Future<Output = F::Output> {
174 let settings = INSTALL_SCRIPT_SETTINGS.try_with(std::sync::Arc::clone).ok();
175 async move {
176 match settings {
177 Some(settings) => INSTALL_SCRIPT_SETTINGS.scope(settings, future).await,
178 None => future.await,
179 }
180 }
181}
182
183fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettingsState> {
184 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettingsState::default()))
185}
186
187pub fn set_script_settings(settings: ScriptSettings) {
190 set_script_settings_with_path_order(settings, false);
191}
192
193#[doc(hidden)]
197pub fn set_script_settings_with_path_order(
198 settings: ScriptSettings,
199 node_bin_dir_precedes_project_bins: bool,
200) {
201 let state = ScriptSettingsState {
202 settings,
203 node_bin_dir_precedes_project_bins,
204 };
205 if INSTALL_SCRIPT_SETTINGS
206 .try_with(|slot| match slot.write() {
207 Ok(mut guard) => *guard = state.clone(),
208 Err(poisoned) => *poisoned.into_inner() = state.clone(),
209 })
210 .is_ok()
211 {
212 return;
213 }
214 match script_settings_lock().write() {
215 Ok(mut guard) => *guard = state,
216 Err(poisoned) => *poisoned.into_inner() = state,
217 }
218}
219
220fn script_settings_state() -> ScriptSettingsState {
221 if let Ok(state) = INSTALL_SCRIPT_SETTINGS.try_with(|slot| match slot.read() {
222 Ok(guard) => guard.clone(),
223 Err(poisoned) => poisoned.into_inner().clone(),
224 }) {
225 return state;
226 }
227 match script_settings_lock().read() {
228 Ok(guard) => guard.clone(),
229 Err(poisoned) => poisoned.into_inner().clone(),
230 }
231}
232
233fn script_settings() -> ScriptSettings {
234 script_settings_state().settings
235}
236
237#[cfg(test)]
238mod scoped_settings_tests {
239 use super::*;
240
241 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
242 async fn install_script_settings_are_isolated_and_propagated() {
243 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
244 let first_barrier = std::sync::Arc::clone(&barrier);
245 let second_barrier = std::sync::Arc::clone(&barrier);
246
247 let first = scope(async move {
248 set_script_settings_with_path_order(
249 ScriptSettings {
250 command: Some("first".to_string()),
251 ..ScriptSettings::default()
252 },
253 true,
254 );
255 first_barrier.wait().await;
256 tokio::spawn(scope_current(async {
257 let state = script_settings_state();
258 (
259 state.settings.command,
260 state.node_bin_dir_precedes_project_bins,
261 )
262 }))
263 .await
264 .unwrap()
265 });
266 let second = scope(async move {
267 set_script_settings(ScriptSettings {
268 command: Some("second".to_string()),
269 ..ScriptSettings::default()
270 });
271 second_barrier.wait().await;
272 tokio::spawn(scope_current(async {
273 let state = script_settings_state();
274 (
275 state.settings.command,
276 state.node_bin_dir_precedes_project_bins,
277 )
278 }))
279 .await
280 .unwrap()
281 });
282
283 let (first, second) = tokio::join!(first, second);
284 assert_eq!(first.0.as_deref(), Some("first"));
285 assert!(first.1);
286 assert_eq!(second.0.as_deref(), Some("second"));
287 assert!(!second.1);
288 }
289}
290
291pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
294 prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf()))
295}
296
297pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString {
299 let path = std::env::var_os("PATH").unwrap_or_default();
300 let mut entries: Vec<PathBuf> = bin_dirs.to_vec();
301 entries.extend(std::env::split_paths(&path));
302 std::env::join_paths(entries).unwrap_or(path)
303}
304
305pub fn order_path_entries(
309 mut project_bins: Vec<PathBuf>,
310 runtime_bin: Option<&Path>,
311 runtime_precedes_project_bins: bool,
312) -> Vec<PathBuf> {
313 let Some(runtime_bin) = runtime_bin else {
314 return project_bins;
315 };
316 if runtime_precedes_project_bins {
317 project_bins.insert(0, runtime_bin.to_path_buf());
318 } else {
319 project_bins.push(runtime_bin.to_path_buf());
320 }
321 project_bins
322}
323
324#[cfg(test)]
325mod path_entry_tests {
326 use super::*;
327
328 #[test]
329 fn wrapper_runtime_leads_project_bins() {
330 let runtime = Path::new("/shim");
331 let project = PathBuf::from("/project/node_modules/.bin");
332 assert_eq!(
333 order_path_entries(vec![project.clone()], Some(runtime), true),
334 vec![runtime.to_path_buf(), project]
335 );
336 }
337
338 #[test]
339 fn selector_runtime_follows_project_bins() {
340 let runtime = Path::new("/opt/node/bin");
341 let project = PathBuf::from("/project/node_modules/.bin");
342 assert_eq!(
343 order_path_entries(vec![project.clone()], Some(runtime), false),
344 vec![project, runtime.to_path_buf()]
345 );
346 }
347}
348
349pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
368 let settings = script_settings();
369 spawn_shell_with_settings(script_cmd, &settings)
370}
371
372fn spawn_shell_with_settings(
373 script_cmd: &str,
374 settings: &ScriptSettings,
375) -> tokio::process::Command {
376 #[cfg(unix)]
377 let mut cmd = {
378 let mut cmd = tokio::process::Command::new(
379 settings
380 .script_shell
381 .as_deref()
382 .unwrap_or_else(|| Path::new("sh")),
383 );
384 cmd.arg("-c").arg(script_cmd);
385 cmd
386 };
387 #[cfg(windows)]
388 let mut cmd = {
389 let mut cmd = tokio::process::Command::new(
390 settings
391 .script_shell
392 .as_deref()
393 .unwrap_or_else(|| Path::new("cmd.exe")),
394 );
395 if settings.script_shell.is_some() {
396 cmd.arg("-c").arg(script_cmd);
397 } else {
398 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
403 }
404 cmd
405 };
406 apply_script_settings_env(&mut cmd, settings);
407 cmd.kill_on_drop(true);
416 cmd
417}
418
419#[cfg(target_os = "macos")]
420fn sbpl_escape(s: &str) -> String {
421 s.replace('\\', "\\\\").replace('"', "\\\"")
422}
423
424#[cfg(target_os = "macos")]
425fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
426 let path = sbpl_escape(&path.to_string_lossy());
427 let rule = format!("(allow file-write* (subpath \"{path}\"))");
428 if !rules.iter().any(|existing| existing == &rule) {
429 rules.push(rule);
430 }
431}
432
433#[cfg(target_os = "macos")]
434fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
435 let mut rules = vec![
436 "(version 1)".to_string(),
437 "(allow default)".to_string(),
438 "(allow network* (local unix))".to_string(),
439 "(deny file-write*)".to_string(),
440 ];
441 if !jail.network {
442 rules.insert(2, "(deny network*)".to_string());
443 }
444
445 for path in [
446 Path::new("/tmp"),
447 Path::new("/private/tmp"),
448 Path::new("/dev"),
449 ] {
450 push_write_rule(&mut rules, path);
451 }
452 for path in [&jail.package_dir, home] {
453 push_write_rule(&mut rules, path);
454 }
455 for path in &jail.write_paths {
456 push_write_rule(&mut rules, path);
457 }
458 for path in [&jail.package_dir, home] {
459 if let Ok(canonical) = path.canonicalize() {
460 push_write_rule(&mut rules, &canonical);
461 }
462 }
463 for path in &jail.write_paths {
464 if let Ok(canonical) = path.canonicalize() {
465 push_write_rule(&mut rules, &canonical);
466 }
467 }
468 rules.join("\n")
469}
470
471#[cfg(target_os = "macos")]
472fn spawn_jailed_shell(
473 script_cmd: &str,
474 settings: &ScriptSettings,
475 jail: &ScriptJail,
476 home: &Path,
477) -> tokio::process::Command {
478 let shell = settings
479 .script_shell
480 .as_deref()
481 .unwrap_or_else(|| Path::new("sh"));
482 let profile = jail_profile(jail, home);
483 let mut cmd = tokio::process::Command::new("sandbox-exec");
484 cmd.arg("-p")
485 .arg(profile)
486 .arg("--")
487 .arg(shell)
488 .arg("-c")
489 .arg(script_cmd);
490 apply_script_settings_env(&mut cmd, settings);
491 cmd.kill_on_drop(true);
493 cmd
494}
495
496#[cfg(target_os = "linux")]
497fn spawn_jailed_shell(
498 script_cmd: &str,
499 settings: &ScriptSettings,
500 jail: &ScriptJail,
501 home: &Path,
502) -> tokio::process::Command {
503 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
504 let jail = jail.clone();
505 let home = home.to_path_buf();
506 unsafe {
507 cmd.pre_exec(move || {
508 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
509 if !jail.network {
510 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
511 }
512 Ok(())
513 });
514 }
515 cmd
516}
517
518#[cfg(not(any(target_os = "linux", target_os = "macos")))]
519fn spawn_jailed_shell(
520 script_cmd: &str,
521 settings: &ScriptSettings,
522 _jail: &ScriptJail,
523 _home: &Path,
524) -> tokio::process::Command {
525 spawn_shell_with_settings(script_cmd, settings)
526}
527
528pub fn shell_quote_arg(arg: &str) -> String {
551 #[cfg(unix)]
552 {
553 let mut out = String::with_capacity(arg.len() + 2);
554 out.push('\'');
555 for ch in arg.chars() {
556 if ch == '\'' {
557 out.push_str("'\\''");
558 } else {
559 out.push(ch);
560 }
561 }
562 out.push('\'');
563 out
564 }
565 #[cfg(windows)]
566 {
567 let mut out = String::with_capacity(arg.len() + 2);
568 out.push('"');
569 let mut backslashes: usize = 0;
570 for ch in arg.chars() {
571 match ch {
572 '\\' => backslashes += 1,
573 '"' => {
574 for _ in 0..backslashes * 2 + 1 {
575 out.push('\\');
576 }
577 out.push('"');
578 backslashes = 0;
579 }
580 '%' => {
591 for _ in 0..backslashes {
592 out.push('\\');
593 }
594 backslashes = 0;
595 out.push_str("%%");
596 }
597 _ => {
598 for _ in 0..backslashes {
599 out.push('\\');
600 }
601 backslashes = 0;
602 out.push(ch);
603 }
604 }
605 }
606 for _ in 0..backslashes * 2 {
607 out.push('\\');
608 }
609 out.push('"');
610 out
611 }
612}
613
614pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
626 if let Some(code) = status.code() {
627 return code;
628 }
629 #[cfg(unix)]
630 {
631 use std::os::unix::process::ExitStatusExt;
632 if let Some(sig) = status.signal() {
633 return 128 + sig;
634 }
635 }
636 1
637}
638
639pub fn aube_user_agent() -> String {
649 format!(
650 "{} {} {}",
651 aube_util::embedder().user_agent,
652 node_platform(),
653 node_arch(),
654 )
655}
656
657fn node_platform() -> &'static str {
658 match std::env::consts::OS {
659 "macos" => "darwin",
660 "windows" => "win32",
661 other => other,
662 }
663}
664
665fn node_arch() -> &'static str {
666 match std::env::consts::ARCH {
673 "x86_64" => "x64",
674 "aarch64" => "arm64",
675 "x86" => "ia32",
676 "powerpc" => "ppc",
677 "powerpc64" => "ppc64",
678 "loongarch64" => "loong64",
679 other => other,
680 }
681}
682
683fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
684 cmd.env_remove("AUBE_AUTH_TOKEN");
691 cmd.env("npm_config_user_agent", aube_user_agent());
696 let aube_exe = std::env::current_exe().ok();
702 if let Some(exe) = aube_exe.as_deref() {
703 cmd.env("npm_execpath", exe);
704 }
705 let node_execpath = settings
712 .node_execpath
713 .as_deref()
714 .or(settings.node_program.as_deref());
715 if let Some(execpath) = node_execpath {
716 cmd.env("npm_node_execpath", execpath);
717 }
718 if let Some(node) = settings.node_program.as_deref().or(node_execpath) {
719 cmd.env("NODE", node);
720 }
721 if let Some(command) = settings.command.as_deref() {
723 cmd.env("npm_command", command);
724 }
725 if let Some(node_gyp_js) = settings.node_gyp_js.as_deref() {
736 cmd.env("npm_config_node_gyp", node_gyp_js);
737 if let Some(exe) = aube_exe.as_deref() {
738 cmd.env("AUBE_NODE_GYP_EXE", exe);
739 }
740 }
741 if let Some(node_options) = settings.node_options.as_deref() {
742 cmd.env("NODE_OPTIONS", node_options);
743 }
744 if let Some(unsafe_perm) = settings.unsafe_perm {
745 cmd.env(
746 "npm_config_unsafe_perm",
747 if unsafe_perm { "true" } else { "false" },
748 );
749 }
750 if settings.shell_emulator {
751 cmd.env("npm_config_shell_emulator", "true");
752 }
753 if settings.http_proxy.is_some() || settings.https_proxy.is_some() {
768 if let Some(https) = settings.https_proxy.as_deref() {
769 cmd.env("HTTPS_PROXY", https);
770 }
771 if let Some(http) = settings.http_proxy.as_deref() {
772 cmd.env("HTTP_PROXY", http);
773 }
774 if let Some(no_proxy) = settings.no_proxy.as_deref() {
775 cmd.env("NO_PROXY", no_proxy);
776 }
777 cmd.env("NODE_USE_ENV_PROXY", "1");
778 }
779 for (key, value) in &settings.extra_env {
785 cmd.env(key, value);
786 }
787}
788
789pub fn apply_npm_manifest_env(
808 cmd: &mut tokio::process::Command,
809 manifest: &PackageJson,
810 script_dir: &Path,
811 lifecycle_script: &str,
812) {
813 for (key, _) in std::env::vars_os() {
814 if key.to_str().is_some_and(|k| k.starts_with("npm_package_")) {
815 cmd.env_remove(&key);
816 }
817 }
818 cmd.env("npm_lifecycle_script", lifecycle_script);
819 cmd.env("npm_package_json", script_dir.join("package.json"));
820 for (key, value) in manifest.npm_package_env() {
821 cmd.env(key, value);
822 }
823}
824
825fn safe_jail_env_key(key: &str) -> bool {
826 const EXACT: &[&str] = &[
827 "PATH",
828 "HOME",
829 "TERM",
830 "LANG",
831 "LC_ALL",
832 "INIT_CWD",
833 "npm_lifecycle_event",
834 "npm_package_name",
835 "npm_package_version",
836 ];
837 if EXACT.contains(&key) {
838 return true;
839 }
840 let lower = key.to_ascii_lowercase();
841 if lower.contains("token")
842 || lower.contains("auth")
843 || lower.contains("password")
844 || lower.contains("credential")
845 || lower.contains("secret")
846 {
847 return false;
848 }
849 key.starts_with("npm_config_")
850}
851
852fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
853 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
854 && !matches!(
855 key,
856 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
857 )
858}
859
860fn jail_home(package_dir: &Path) -> PathBuf {
861 let mut hasher = DefaultHasher::new();
862 package_dir.hash(&mut hasher);
863 let hash = hasher.finish();
864 let name = package_dir
865 .file_name()
866 .and_then(|s| s.to_str())
867 .unwrap_or("package")
868 .chars()
869 .map(|c| {
870 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
871 c
872 } else {
873 '_'
874 }
875 })
876 .collect::<String>();
877 std::env::temp_dir()
878 .join("aube-jail")
879 .join(std::process::id().to_string())
880 .join(format!("{name}-{hash:016x}"))
881}
882
883fn apply_jail_env(
884 cmd: &mut tokio::process::Command,
885 path_env: &std::ffi::OsStr,
886 home: &Path,
887 project_root: &Path,
888 manifest: &PackageJson,
889 script_name: &str,
890 extra_env: &[String],
891) {
892 cmd.env_clear();
893 cmd.env("PATH", path_env)
894 .env("HOME", home)
895 .env("TMPDIR", home)
896 .env("TMP", home)
897 .env("TEMP", home)
898 .env("npm_lifecycle_event", script_name);
899 if std::env::var_os("INIT_CWD").is_none() {
900 cmd.env("INIT_CWD", project_root);
901 }
902 if let Some(ref name) = manifest.name {
903 cmd.env("npm_package_name", name);
904 }
905 if let Some(ref version) = manifest.version {
906 cmd.env("npm_package_version", version);
907 }
908 for (key, val) in std::env::vars_os() {
909 let Some(key_str) = key.to_str() else {
910 continue;
911 };
912 if inherit_jail_env_key(key_str, extra_env) {
913 cmd.env(key, val);
914 }
915 }
916}
917
918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
922pub enum LifecycleHook {
923 PreInstall,
924 Install,
925 PostInstall,
926 Prepare,
927}
928
929impl LifecycleHook {
930 pub fn script_name(self) -> &'static str {
931 match self {
932 Self::PreInstall => "preinstall",
933 Self::Install => "install",
934 Self::PostInstall => "postinstall",
935 Self::Prepare => "prepare",
936 }
937 }
938}
939
940pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
944 LifecycleHook::PreInstall,
945 LifecycleHook::Install,
946 LifecycleHook::PostInstall,
947];
948
949#[cfg(unix)]
957static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
958
959#[cfg(unix)]
964pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
965 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
966}
967
968#[cfg(not(unix))]
973pub fn set_saved_stderr_fd(_fd: i32) {}
974
975#[cfg(unix)]
980pub fn child_stderr() -> std::process::Stdio {
981 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
982 if fd < 0 {
983 return std::process::Stdio::inherit();
984 }
985 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
990 match borrowed.try_clone_to_owned() {
991 Ok(owned) => std::process::Stdio::from(owned),
992 Err(_) => std::process::Stdio::inherit(),
993 }
994}
995
996#[cfg(not(unix))]
997pub fn child_stderr() -> std::process::Stdio {
998 std::process::Stdio::inherit()
999}
1000
1001#[cfg(unix)]
1017pub fn write_line_to_real_stderr(line: &str) {
1018 use std::io::Write;
1019 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1020 let fd = if saved >= 0 { saved } else { 2 };
1021 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1028 let Ok(owned) = borrowed.try_clone_to_owned() else {
1029 return;
1030 };
1031 let mut file = std::fs::File::from(owned);
1032 let mut buf = String::with_capacity(line.len() + 1);
1033 buf.push_str(line);
1034 buf.push('\n');
1035 let _ = file.write_all(buf.as_bytes());
1036}
1037
1038#[cfg(not(unix))]
1039pub fn write_line_to_real_stderr(line: &str) {
1040 eprintln!("{line}");
1041}
1042
1043async fn run_command_killing_descendants(
1079 mut cmd: tokio::process::Command,
1080 script_name: &str,
1081) -> Result<std::process::ExitStatus, Error> {
1082 let mut child = cmd
1083 .spawn()
1084 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1085 #[cfg(windows)]
1086 let _job = match windows_job::JobObject::new() {
1087 Ok(job) => {
1088 if let Some(handle) = child.raw_handle()
1092 && let Err(err) = job.assign(handle)
1093 {
1094 tracing::warn!(
1101 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1102 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
1103 grandchildren may be orphaned if the script is aborted"
1104 );
1105 }
1106 Some(job)
1107 }
1108 Err(err) => {
1109 tracing::warn!(
1110 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1111 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
1112 running without orphan-reaping — grandchildren may leak if aborted"
1113 );
1114 None
1115 }
1116 };
1117 child
1118 .wait()
1119 .await
1120 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
1121}
1122
1123#[allow(clippy::too_many_arguments)]
1140pub async fn run_script(
1141 script_dir: &Path,
1142 project_root: &Path,
1143 modules_dir_name: &str,
1144 manifest: &PackageJson,
1145 script_name: &str,
1146 script_cmd: &str,
1147 extra_bin_dirs: &[&Path],
1148 jail: Option<&ScriptJail>,
1149) -> Result<(), Error> {
1150 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
1155 .with_meta_fn(|| {
1156 let pkg = manifest.name.as_deref().unwrap_or("(root)");
1157 format!(
1158 r#"{{"pkg":{},"script":{}}}"#,
1159 aube_util::diag::jstr(pkg),
1160 aube_util::diag::jstr(script_name)
1161 )
1162 });
1163 let project_bin = project_root.join(modules_dir_name).join(".bin");
1171 let state = script_settings_state();
1172 let settings = &state.settings;
1173 let path = std::env::var_os("PATH").unwrap_or_default();
1174 let mut project_bins: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
1175 for dir in extra_bin_dirs {
1176 project_bins.push(dir.to_path_buf());
1177 }
1178 project_bins.push(project_bin);
1179 let mut entries = order_path_entries(
1180 project_bins,
1181 settings.node_bin_dir.as_deref(),
1182 state.node_bin_dir_precedes_project_bins,
1183 );
1184 entries.extend(std::env::split_paths(&path));
1185 let new_path = std::env::join_paths(entries).unwrap_or(path);
1186 let jail_home = jail.map(|j| jail_home(&j.package_dir));
1187 if let Some(home) = &jail_home {
1188 std::fs::create_dir_all(home)
1189 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1190 }
1191 let mut cmd = match (jail, jail_home.as_deref()) {
1192 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, settings, jail, home),
1193 _ => spawn_shell_with_settings(script_cmd, settings),
1194 };
1195 cmd.current_dir(script_dir)
1196 .stderr(child_stderr())
1197 .env("PATH", &new_path)
1198 .env("npm_lifecycle_event", script_name);
1199
1200 if std::env::var_os("INIT_CWD").is_none() {
1207 cmd.env("INIT_CWD", project_root);
1208 }
1209
1210 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
1211 apply_jail_env(
1212 &mut cmd,
1213 &new_path,
1214 home,
1215 project_root,
1216 manifest,
1217 script_name,
1218 &jail.env,
1219 );
1220 apply_script_settings_env(&mut cmd, settings);
1221 }
1222
1223 apply_npm_manifest_env(&mut cmd, manifest, script_dir, script_cmd);
1227
1228 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
1229 let status = run_command_killing_descendants(cmd, script_name).await?;
1230
1231 if !status.success() {
1232 return Err(Error::NonZeroExit {
1233 script: script_name.to_string(),
1234 code: status.code(),
1235 });
1236 }
1237
1238 Ok(())
1239}
1240
1241pub async fn run_root_hook(
1247 project_dir: &Path,
1248 modules_dir_name: &str,
1249 manifest: &PackageJson,
1250 hook: LifecycleHook,
1251) -> Result<bool, Error> {
1252 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
1253}
1254
1255pub async fn run_root_script_by_name(
1262 project_dir: &Path,
1263 modules_dir_name: &str,
1264 manifest: &PackageJson,
1265 name: &str,
1266) -> Result<bool, Error> {
1267 let Some(script_cmd) = manifest.scripts.get(name) else {
1268 return Ok(false);
1269 };
1270 run_script(
1271 project_dir,
1272 project_dir,
1273 modules_dir_name,
1274 manifest,
1275 name,
1276 script_cmd,
1277 &[],
1278 None,
1279 )
1280 .await?;
1281 Ok(true)
1282}
1283
1284pub fn implicit_install_script(
1297 manifest: &PackageJson,
1298 has_binding_gyp: bool,
1299) -> Option<&'static str> {
1300 if !has_binding_gyp {
1301 return None;
1302 }
1303 if manifest
1304 .scripts
1305 .contains_key(LifecycleHook::Install.script_name())
1306 || manifest
1307 .scripts
1308 .contains_key(LifecycleHook::PreInstall.script_name())
1309 {
1310 return None;
1311 }
1312 Some("node-gyp rebuild")
1313}
1314
1315pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
1319 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
1320}
1321
1322pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
1327 if DEP_LIFECYCLE_HOOKS
1328 .iter()
1329 .any(|h| manifest.scripts.contains_key(h.script_name()))
1330 {
1331 return true;
1332 }
1333 default_install_script(package_dir, manifest).is_some()
1334}
1335
1336#[allow(clippy::too_many_arguments)]
1366pub async fn run_dep_hook(
1367 package_dir: &Path,
1368 dep_modules_dir: &Path,
1369 project_root: &Path,
1370 modules_dir_name: &str,
1371 manifest: &PackageJson,
1372 hook: LifecycleHook,
1373 tool_bin_dirs: &[&Path],
1374 jail: Option<&ScriptJail>,
1375) -> Result<bool, Error> {
1376 let name = hook.script_name();
1377 let script_cmd: &str = match manifest.scripts.get(name) {
1378 Some(s) => s.as_str(),
1379 None => match hook {
1380 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1381 Some(s) => s,
1382 None => return Ok(false),
1383 },
1384 _ => return Ok(false),
1385 },
1386 };
1387 let dep_bin_dir = dep_modules_dir.join(".bin");
1388 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1389 bin_dirs.push(&dep_bin_dir);
1390 bin_dirs.extend(tool_bin_dirs.iter().copied());
1391 run_script(
1392 package_dir,
1393 project_root,
1394 modules_dir_name,
1395 manifest,
1396 name,
1397 script_cmd,
1398 &bin_dirs,
1399 jail,
1400 )
1401 .await?;
1402 Ok(true)
1403}
1404
1405#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1406pub enum Error {
1407 #[error("failed to spawn script {0}: {1}")]
1408 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1409 Spawn(String, String),
1410 #[error("script `{script}` exited with code {code:?}")]
1411 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1412 NonZeroExit { script: String, code: Option<i32> },
1413}
1414
1415#[cfg(test)]
1416mod user_agent_tests {
1417 use super::*;
1418
1419 #[test]
1420 fn user_agent_uses_node_style_platform_and_arch() {
1421 let ua = aube_user_agent();
1422 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1424 let parts: Vec<&str> = ua.split(' ').collect();
1425 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1426 let platform = parts[1];
1428 assert!(
1429 matches!(
1430 platform,
1431 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1432 ),
1433 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1434 );
1435 let arch = parts[2];
1439 assert!(
1440 matches!(
1441 arch,
1442 "x64"
1443 | "arm64"
1444 | "ia32"
1445 | "arm"
1446 | "ppc"
1447 | "ppc64"
1448 | "loong64"
1449 | "mips"
1450 | "riscv64"
1451 | "s390x"
1452 ),
1453 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1454 );
1455 }
1456}
1457
1458#[cfg(test)]
1459mod jail_tests {
1460 use super::*;
1461
1462 #[test]
1463 fn jail_home_uses_full_package_path() {
1464 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1465 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1466
1467 assert_ne!(a, b);
1468 assert!(
1469 a.file_name()
1470 .unwrap()
1471 .to_string_lossy()
1472 .starts_with("native-")
1473 );
1474 assert!(
1475 b.file_name()
1476 .unwrap()
1477 .to_string_lossy()
1478 .starts_with("native-")
1479 );
1480 }
1481
1482 #[test]
1483 fn jail_home_cleanup_removes_temp_home() {
1484 let package_dir = std::env::temp_dir()
1485 .join("aube-jail-cleanup-test")
1486 .join(std::process::id().to_string())
1487 .join("node_modules")
1488 .join("native");
1489 let jail = ScriptJail::new(&package_dir);
1490 let home = jail_home(&package_dir);
1491 std::fs::create_dir_all(home.join(".cache")).unwrap();
1492 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1493
1494 {
1495 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1496 }
1497
1498 assert!(!home.exists());
1499 }
1500
1501 #[test]
1502 fn parent_env_cannot_override_explicit_jail_metadata() {
1503 for key in [
1504 "PATH",
1505 "HOME",
1506 "npm_lifecycle_event",
1507 "npm_package_name",
1508 "npm_package_version",
1509 ] {
1510 assert!(!inherit_jail_env_key(key, &[]));
1511 }
1512 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1513 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1514 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1515 assert!(inherit_jail_env_key(
1516 "SHARP_DIST_BASE_URL",
1517 &["SHARP_DIST_BASE_URL".to_string()]
1518 ));
1519 }
1520
1521 #[test]
1522 fn jail_env_preserves_script_settings_after_clear() {
1523 let mut cmd = tokio::process::Command::new("node");
1524 let manifest = PackageJson {
1525 name: Some("pkg".to_string()),
1526 version: Some("1.2.3".to_string()),
1527 ..Default::default()
1528 };
1529 let settings = ScriptSettings {
1530 node_options: Some("--conditions=aube".to_string()),
1531 unsafe_perm: Some(false),
1532 shell_emulator: true,
1533 ..Default::default()
1534 };
1535
1536 apply_jail_env(
1537 &mut cmd,
1538 std::ffi::OsStr::new("/bin"),
1539 Path::new("/tmp/aube-jail/home"),
1540 Path::new("/tmp/project"),
1541 &manifest,
1542 "postinstall",
1543 &[],
1544 );
1545 apply_script_settings_env(&mut cmd, &settings);
1546
1547 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1548 let env = |name: &str| {
1549 envs.iter()
1550 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1551 .and_then(|(_, val)| *val)
1552 .and_then(|val| val.to_str())
1553 };
1554
1555 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1556 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1557 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1558 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1559 assert_eq!(env("npm_package_name"), Some("pkg"));
1560 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1561 }
1562
1563 fn proxy_env(settings: ScriptSettings) -> impl Fn(&str) -> Option<String> {
1564 let mut cmd = tokio::process::Command::new("node");
1565 apply_script_settings_env(&mut cmd, &settings);
1566 let envs: Vec<_> = cmd
1567 .as_std()
1568 .get_envs()
1569 .map(|(k, v)| {
1570 (
1571 k.to_string_lossy().into_owned(),
1572 v.map(|v| v.to_string_lossy().into_owned()),
1573 )
1574 })
1575 .collect();
1576 move |name: &str| {
1577 envs.iter()
1578 .find(|(k, _)| k == name)
1579 .and_then(|(_, v)| v.clone())
1580 }
1581 }
1582
1583 #[test]
1584 fn proxy_vars_stamped_when_proxy_configured() {
1585 let env = proxy_env(ScriptSettings {
1586 https_proxy: Some("http://proxy.example:8080".to_string()),
1587 http_proxy: Some("http://proxy.example:8080".to_string()),
1588 no_proxy: Some("localhost,127.0.0.1".to_string()),
1589 ..Default::default()
1590 });
1591 assert_eq!(
1592 env("HTTPS_PROXY").as_deref(),
1593 Some("http://proxy.example:8080")
1594 );
1595 assert_eq!(
1596 env("HTTP_PROXY").as_deref(),
1597 Some("http://proxy.example:8080")
1598 );
1599 assert_eq!(env("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
1600 assert_eq!(env("NODE_USE_ENV_PROXY").as_deref(), Some("1"));
1603 }
1604
1605 #[test]
1606 fn proxy_block_skipped_when_no_proxy_configured() {
1607 let env = proxy_env(ScriptSettings::default());
1611 assert_eq!(env("HTTPS_PROXY"), None);
1612 assert_eq!(env("HTTP_PROXY"), None);
1613 assert_eq!(env("NO_PROXY"), None);
1614 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1615 }
1616
1617 #[test]
1618 fn no_proxy_alone_does_not_trigger_passthrough() {
1619 let env = proxy_env(ScriptSettings {
1622 no_proxy: Some("example.com".to_string()),
1623 ..Default::default()
1624 });
1625 assert_eq!(env("NO_PROXY"), None);
1626 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1627 }
1628
1629 #[test]
1630 fn wrapper_node_and_execpath_are_stamped_distinctly() {
1631 let env = proxy_env(ScriptSettings {
1635 node_program: Some(PathBuf::from("/shim/node")),
1636 node_execpath: Some(PathBuf::from("/real/node-24.4.1/bin/node")),
1637 extra_env: vec![("MYTOOL_WRAPPED".into(), "1".into())],
1638 ..Default::default()
1639 });
1640 assert_eq!(env("NODE").as_deref(), Some("/shim/node"));
1641 assert_eq!(
1642 env("npm_node_execpath").as_deref(),
1643 Some("/real/node-24.4.1/bin/node")
1644 );
1645 assert_eq!(env("MYTOOL_WRAPPED").as_deref(), Some("1"));
1646 }
1647
1648 #[test]
1649 fn node_execpath_falls_back_to_node_program() {
1650 let env = proxy_env(ScriptSettings {
1652 node_program: Some(PathBuf::from("/opt/node/bin/node")),
1653 ..Default::default()
1654 });
1655 assert_eq!(env("NODE").as_deref(), Some("/opt/node/bin/node"));
1656 assert_eq!(
1657 env("npm_node_execpath").as_deref(),
1658 Some("/opt/node/bin/node")
1659 );
1660 }
1661}
1662
1663#[cfg(all(test, windows))]
1664mod windows_quote_tests {
1665 use super::shell_quote_arg;
1666
1667 #[test]
1668 fn windows_path_backslash_not_doubled() {
1669 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1670 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1671 }
1672
1673 #[test]
1674 fn windows_trailing_backslash_doubled_before_close_quote() {
1675 let q = shell_quote_arg(r"C:\path\");
1676 assert_eq!(q, "\"C:\\path\\\\\"");
1677 }
1678
1679 #[test]
1680 fn windows_quote_in_arg_escapes_with_backslash() {
1681 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1682 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1683 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1684 }
1685}
1686
1687#[cfg(all(test, windows))]
1694mod windows_job_object_tests {
1695 use super::*;
1696 use std::time::{Duration, Instant};
1697 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1698 use windows_sys::Win32::System::Threading::{
1699 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1700 };
1701
1702 fn is_process_alive(pid: u32) -> bool {
1703 unsafe {
1707 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1708 if handle.is_null() {
1709 return false;
1710 }
1711 let mut code: u32 = 0;
1712 let ok = GetExitCodeProcess(handle, &mut code);
1713 CloseHandle(handle);
1714 ok != 0 && code == STILL_ACTIVE as u32
1715 }
1716 }
1717
1718 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1719 let start = Instant::now();
1720 while !check() {
1721 if start.elapsed() > timeout {
1722 return false;
1723 }
1724 tokio::time::sleep(Duration::from_millis(75)).await;
1725 }
1726 true
1727 }
1728
1729 #[tokio::test]
1730 async fn aborting_script_kills_grandchildren() {
1731 let nanos = std::time::SystemTime::now()
1735 .duration_since(std::time::UNIX_EPOCH)
1736 .unwrap_or_default()
1737 .as_nanos();
1738 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1739 let script = format!(
1748 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1749 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1750 & ping -n 10 127.0.0.1 >nul",
1751 pid_file.display()
1752 );
1753 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1754 let task = tokio::spawn(async move {
1755 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1756 });
1757
1758 let appeared = wait_until(
1759 || {
1760 std::fs::read_to_string(&pid_file)
1761 .ok()
1762 .and_then(|pid| pid.trim().parse::<u32>().ok())
1763 .is_some()
1764 },
1765 Duration::from_secs(20),
1766 )
1767 .await;
1768 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1769 let pid: u32 = std::fs::read_to_string(&pid_file)
1770 .expect("read pid file")
1771 .trim()
1772 .parse()
1773 .expect("pid file was parseable before reading");
1774 assert!(
1775 is_process_alive(pid),
1776 "grandchild pid {pid} not alive immediately after writing pid file"
1777 );
1778
1779 task.abort();
1784 let _ = task.await;
1785
1786 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1787 let _ = std::fs::remove_file(&pid_file);
1788 assert!(
1789 reaped,
1790 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1791 );
1792 }
1793}