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>,
44 pub node_exe: Option<PathBuf>,
47}
48
49#[derive(Debug, Clone)]
51pub struct ScriptJail {
52 pub package_dir: PathBuf,
53 pub env: Vec<String>,
54 pub read_paths: Vec<PathBuf>,
55 pub write_paths: Vec<PathBuf>,
56 pub network: bool,
57}
58
59impl ScriptJail {
60 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
61 Self {
62 package_dir: package_dir.into(),
63 env: Vec::new(),
64 read_paths: Vec::new(),
65 write_paths: Vec::new(),
66 network: false,
67 }
68 }
69
70 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
71 self.env = env.into_iter().collect();
72 self
73 }
74
75 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
76 self.read_paths = paths.into_iter().collect();
77 self
78 }
79
80 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
81 self.write_paths = paths.into_iter().collect();
82 self
83 }
84
85 pub fn with_network(mut self, network: bool) -> Self {
86 self.network = network;
87 self
88 }
89}
90
91pub struct ScriptJailHomeCleanup {
92 path: PathBuf,
93}
94
95impl ScriptJailHomeCleanup {
96 pub fn new(jail: &ScriptJail) -> Self {
97 Self {
98 path: jail_home(&jail.package_dir),
99 }
100 }
101}
102
103impl Drop for ScriptJailHomeCleanup {
104 fn drop(&mut self) {
105 if self.path.exists()
106 && let Err(err) = std::fs::remove_dir_all(&self.path)
107 {
108 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
109 }
110 }
111}
112
113static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
114 std::sync::OnceLock::new();
115
116fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
117 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
118}
119
120pub fn set_script_settings(settings: ScriptSettings) {
124 match script_settings_lock().write() {
125 Ok(mut guard) => *guard = settings,
126 Err(poisoned) => *poisoned.into_inner() = settings,
127 }
128}
129
130fn script_settings() -> ScriptSettings {
131 match script_settings_lock().read() {
132 Ok(guard) => guard.clone(),
133 Err(poisoned) => poisoned.into_inner().clone(),
134 }
135}
136
137pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
140 prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf()))
141}
142
143pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString {
145 let path = std::env::var_os("PATH").unwrap_or_default();
146 let mut entries: Vec<PathBuf> = bin_dirs.to_vec();
147 entries.extend(std::env::split_paths(&path));
148 std::env::join_paths(entries).unwrap_or(path)
149}
150
151pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
170 let settings = script_settings();
171 spawn_shell_with_settings(script_cmd, &settings)
172}
173
174fn spawn_shell_with_settings(
175 script_cmd: &str,
176 settings: &ScriptSettings,
177) -> tokio::process::Command {
178 #[cfg(unix)]
179 let mut cmd = {
180 let mut cmd = tokio::process::Command::new(
181 settings
182 .script_shell
183 .as_deref()
184 .unwrap_or_else(|| Path::new("sh")),
185 );
186 cmd.arg("-c").arg(script_cmd);
187 cmd
188 };
189 #[cfg(windows)]
190 let mut cmd = {
191 let mut cmd = tokio::process::Command::new(
192 settings
193 .script_shell
194 .as_deref()
195 .unwrap_or_else(|| Path::new("cmd.exe")),
196 );
197 if settings.script_shell.is_some() {
198 cmd.arg("-c").arg(script_cmd);
199 } else {
200 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
205 }
206 cmd
207 };
208 apply_script_settings_env(&mut cmd, settings);
209 cmd.kill_on_drop(true);
218 cmd
219}
220
221#[cfg(target_os = "macos")]
222fn sbpl_escape(s: &str) -> String {
223 s.replace('\\', "\\\\").replace('"', "\\\"")
224}
225
226#[cfg(target_os = "macos")]
227fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
228 let path = sbpl_escape(&path.to_string_lossy());
229 let rule = format!("(allow file-write* (subpath \"{path}\"))");
230 if !rules.iter().any(|existing| existing == &rule) {
231 rules.push(rule);
232 }
233}
234
235#[cfg(target_os = "macos")]
236fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
237 let mut rules = vec![
238 "(version 1)".to_string(),
239 "(allow default)".to_string(),
240 "(allow network* (local unix))".to_string(),
241 "(deny file-write*)".to_string(),
242 ];
243 if !jail.network {
244 rules.insert(2, "(deny network*)".to_string());
245 }
246
247 for path in [
248 Path::new("/tmp"),
249 Path::new("/private/tmp"),
250 Path::new("/dev"),
251 ] {
252 push_write_rule(&mut rules, path);
253 }
254 for path in [&jail.package_dir, home] {
255 push_write_rule(&mut rules, path);
256 }
257 for path in &jail.write_paths {
258 push_write_rule(&mut rules, path);
259 }
260 for path in [&jail.package_dir, home] {
261 if let Ok(canonical) = path.canonicalize() {
262 push_write_rule(&mut rules, &canonical);
263 }
264 }
265 for path in &jail.write_paths {
266 if let Ok(canonical) = path.canonicalize() {
267 push_write_rule(&mut rules, &canonical);
268 }
269 }
270 rules.join("\n")
271}
272
273#[cfg(target_os = "macos")]
274fn spawn_jailed_shell(
275 script_cmd: &str,
276 settings: &ScriptSettings,
277 jail: &ScriptJail,
278 home: &Path,
279) -> tokio::process::Command {
280 let shell = settings
281 .script_shell
282 .as_deref()
283 .unwrap_or_else(|| Path::new("sh"));
284 let profile = jail_profile(jail, home);
285 let mut cmd = tokio::process::Command::new("sandbox-exec");
286 cmd.arg("-p")
287 .arg(profile)
288 .arg("--")
289 .arg(shell)
290 .arg("-c")
291 .arg(script_cmd);
292 apply_script_settings_env(&mut cmd, settings);
293 cmd.kill_on_drop(true);
295 cmd
296}
297
298#[cfg(target_os = "linux")]
299fn spawn_jailed_shell(
300 script_cmd: &str,
301 settings: &ScriptSettings,
302 jail: &ScriptJail,
303 home: &Path,
304) -> tokio::process::Command {
305 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
306 let jail = jail.clone();
307 let home = home.to_path_buf();
308 unsafe {
309 cmd.pre_exec(move || {
310 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
311 if !jail.network {
312 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
313 }
314 Ok(())
315 });
316 }
317 cmd
318}
319
320#[cfg(not(any(target_os = "linux", target_os = "macos")))]
321fn spawn_jailed_shell(
322 script_cmd: &str,
323 settings: &ScriptSettings,
324 _jail: &ScriptJail,
325 _home: &Path,
326) -> tokio::process::Command {
327 spawn_shell_with_settings(script_cmd, settings)
328}
329
330pub fn shell_quote_arg(arg: &str) -> String {
353 #[cfg(unix)]
354 {
355 let mut out = String::with_capacity(arg.len() + 2);
356 out.push('\'');
357 for ch in arg.chars() {
358 if ch == '\'' {
359 out.push_str("'\\''");
360 } else {
361 out.push(ch);
362 }
363 }
364 out.push('\'');
365 out
366 }
367 #[cfg(windows)]
368 {
369 let mut out = String::with_capacity(arg.len() + 2);
370 out.push('"');
371 let mut backslashes: usize = 0;
372 for ch in arg.chars() {
373 match ch {
374 '\\' => backslashes += 1,
375 '"' => {
376 for _ in 0..backslashes * 2 + 1 {
377 out.push('\\');
378 }
379 out.push('"');
380 backslashes = 0;
381 }
382 '%' => {
393 for _ in 0..backslashes {
394 out.push('\\');
395 }
396 backslashes = 0;
397 out.push_str("%%");
398 }
399 _ => {
400 for _ in 0..backslashes {
401 out.push('\\');
402 }
403 backslashes = 0;
404 out.push(ch);
405 }
406 }
407 }
408 for _ in 0..backslashes * 2 {
409 out.push('\\');
410 }
411 out.push('"');
412 out
413 }
414}
415
416pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
428 if let Some(code) = status.code() {
429 return code;
430 }
431 #[cfg(unix)]
432 {
433 use std::os::unix::process::ExitStatusExt;
434 if let Some(sig) = status.signal() {
435 return 128 + sig;
436 }
437 }
438 1
439}
440
441pub fn aube_user_agent() -> String {
451 format!(
452 "aube/{} {} {}",
453 env!("CARGO_PKG_VERSION"),
454 node_platform(),
455 node_arch(),
456 )
457}
458
459fn node_platform() -> &'static str {
460 match std::env::consts::OS {
461 "macos" => "darwin",
462 "windows" => "win32",
463 other => other,
464 }
465}
466
467fn node_arch() -> &'static str {
468 match std::env::consts::ARCH {
475 "x86_64" => "x64",
476 "aarch64" => "arm64",
477 "x86" => "ia32",
478 "powerpc" => "ppc",
479 "powerpc64" => "ppc64",
480 "loongarch64" => "loong64",
481 other => other,
482 }
483}
484
485fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
486 cmd.env_remove("AUBE_AUTH_TOKEN");
493 cmd.env("npm_config_user_agent", aube_user_agent());
498 if let Some(node_options) = settings.node_options.as_deref() {
499 cmd.env("NODE_OPTIONS", node_options);
500 }
501 if let Some(unsafe_perm) = settings.unsafe_perm {
502 cmd.env(
503 "npm_config_unsafe_perm",
504 if unsafe_perm { "true" } else { "false" },
505 );
506 }
507 if settings.shell_emulator {
508 cmd.env("npm_config_shell_emulator", "true");
509 }
510}
511
512fn safe_jail_env_key(key: &str) -> bool {
513 const EXACT: &[&str] = &[
514 "PATH",
515 "HOME",
516 "TERM",
517 "LANG",
518 "LC_ALL",
519 "INIT_CWD",
520 "npm_lifecycle_event",
521 "npm_package_name",
522 "npm_package_version",
523 ];
524 if EXACT.contains(&key) {
525 return true;
526 }
527 let lower = key.to_ascii_lowercase();
528 if lower.contains("token")
529 || lower.contains("auth")
530 || lower.contains("password")
531 || lower.contains("credential")
532 || lower.contains("secret")
533 {
534 return false;
535 }
536 key.starts_with("npm_config_")
537}
538
539fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
540 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
541 && !matches!(
542 key,
543 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
544 )
545}
546
547fn jail_home(package_dir: &Path) -> PathBuf {
548 let mut hasher = DefaultHasher::new();
549 package_dir.hash(&mut hasher);
550 let hash = hasher.finish();
551 let name = package_dir
552 .file_name()
553 .and_then(|s| s.to_str())
554 .unwrap_or("package")
555 .chars()
556 .map(|c| {
557 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
558 c
559 } else {
560 '_'
561 }
562 })
563 .collect::<String>();
564 std::env::temp_dir()
565 .join("aube-jail")
566 .join(std::process::id().to_string())
567 .join(format!("{name}-{hash:016x}"))
568}
569
570fn apply_jail_env(
571 cmd: &mut tokio::process::Command,
572 path_env: &std::ffi::OsStr,
573 home: &Path,
574 project_root: &Path,
575 manifest: &PackageJson,
576 script_name: &str,
577 extra_env: &[String],
578) {
579 cmd.env_clear();
580 cmd.env("PATH", path_env)
581 .env("HOME", home)
582 .env("TMPDIR", home)
583 .env("TMP", home)
584 .env("TEMP", home)
585 .env("npm_lifecycle_event", script_name);
586 if std::env::var_os("INIT_CWD").is_none() {
587 cmd.env("INIT_CWD", project_root);
588 }
589 if let Some(ref name) = manifest.name {
590 cmd.env("npm_package_name", name);
591 }
592 if let Some(ref version) = manifest.version {
593 cmd.env("npm_package_version", version);
594 }
595 for (key, val) in std::env::vars_os() {
596 let Some(key_str) = key.to_str() else {
597 continue;
598 };
599 if inherit_jail_env_key(key_str, extra_env) {
600 cmd.env(key, val);
601 }
602 }
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum LifecycleHook {
610 PreInstall,
611 Install,
612 PostInstall,
613 Prepare,
614}
615
616impl LifecycleHook {
617 pub fn script_name(self) -> &'static str {
618 match self {
619 Self::PreInstall => "preinstall",
620 Self::Install => "install",
621 Self::PostInstall => "postinstall",
622 Self::Prepare => "prepare",
623 }
624 }
625}
626
627pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
631 LifecycleHook::PreInstall,
632 LifecycleHook::Install,
633 LifecycleHook::PostInstall,
634];
635
636#[cfg(unix)]
644static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
645
646#[cfg(unix)]
651pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
652 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
653}
654
655#[cfg(not(unix))]
660pub fn set_saved_stderr_fd(_fd: i32) {}
661
662#[cfg(unix)]
667pub fn child_stderr() -> std::process::Stdio {
668 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
669 if fd < 0 {
670 return std::process::Stdio::inherit();
671 }
672 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
677 match borrowed.try_clone_to_owned() {
678 Ok(owned) => std::process::Stdio::from(owned),
679 Err(_) => std::process::Stdio::inherit(),
680 }
681}
682
683#[cfg(not(unix))]
684pub fn child_stderr() -> std::process::Stdio {
685 std::process::Stdio::inherit()
686}
687
688#[cfg(unix)]
704pub fn write_line_to_real_stderr(line: &str) {
705 use std::io::Write;
706 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
707 let fd = if saved >= 0 { saved } else { 2 };
708 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
715 let Ok(owned) = borrowed.try_clone_to_owned() else {
716 return;
717 };
718 let mut file = std::fs::File::from(owned);
719 let mut buf = String::with_capacity(line.len() + 1);
720 buf.push_str(line);
721 buf.push('\n');
722 let _ = file.write_all(buf.as_bytes());
723}
724
725#[cfg(not(unix))]
726pub fn write_line_to_real_stderr(line: &str) {
727 eprintln!("{line}");
728}
729
730async fn run_command_killing_descendants(
766 mut cmd: tokio::process::Command,
767 script_name: &str,
768) -> Result<std::process::ExitStatus, Error> {
769 let mut child = cmd
770 .spawn()
771 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
772 #[cfg(windows)]
773 let _job = match windows_job::JobObject::new() {
774 Ok(job) => {
775 if let Some(handle) = child.raw_handle()
779 && let Err(err) = job.assign(handle)
780 {
781 tracing::warn!(
788 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
789 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
790 grandchildren may be orphaned if the script is aborted"
791 );
792 }
793 Some(job)
794 }
795 Err(err) => {
796 tracing::warn!(
797 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
798 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
799 running without orphan-reaping — grandchildren may leak if aborted"
800 );
801 None
802 }
803 };
804 child
805 .wait()
806 .await
807 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
808}
809
810#[allow(clippy::too_many_arguments)]
827pub async fn run_script(
828 script_dir: &Path,
829 project_root: &Path,
830 modules_dir_name: &str,
831 manifest: &PackageJson,
832 script_name: &str,
833 script_cmd: &str,
834 extra_bin_dirs: &[&Path],
835 jail: Option<&ScriptJail>,
836) -> Result<(), Error> {
837 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
842 .with_meta_fn(|| {
843 let pkg = manifest.name.as_deref().unwrap_or("(root)");
844 format!(
845 r#"{{"pkg":{},"script":{}}}"#,
846 aube_util::diag::jstr(pkg),
847 aube_util::diag::jstr(script_name)
848 )
849 });
850 let project_bin = project_root.join(modules_dir_name).join(".bin");
858 let settings = script_settings();
859 let path = std::env::var_os("PATH").unwrap_or_default();
860 let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 2);
861 for dir in extra_bin_dirs {
862 entries.push(dir.to_path_buf());
863 }
864 entries.push(project_bin);
865 if let Some(dir) = &settings.node_bin_dir {
870 entries.push(dir.clone());
871 }
872 entries.extend(std::env::split_paths(&path));
873 let new_path = std::env::join_paths(entries).unwrap_or(path);
874 let jail_home = jail.map(|j| jail_home(&j.package_dir));
875 if let Some(home) = &jail_home {
876 std::fs::create_dir_all(home)
877 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
878 }
879 let mut cmd = match (jail, jail_home.as_deref()) {
880 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
881 _ => spawn_shell_with_settings(script_cmd, &settings),
882 };
883 cmd.current_dir(script_dir)
884 .stderr(child_stderr())
885 .env("PATH", &new_path)
886 .env("npm_lifecycle_event", script_name);
887 if let Some(node_exe) = &settings.node_exe {
890 cmd.env("npm_node_execpath", node_exe).env("NODE", node_exe);
891 }
892
893 if std::env::var_os("INIT_CWD").is_none() {
900 cmd.env("INIT_CWD", project_root);
901 }
902
903 if let Some(ref name) = manifest.name {
904 cmd.env("npm_package_name", name);
905 }
906 if let Some(ref version) = manifest.version {
907 cmd.env("npm_package_version", version);
908 }
909 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
910 apply_jail_env(
911 &mut cmd,
912 &new_path,
913 home,
914 project_root,
915 manifest,
916 script_name,
917 &jail.env,
918 );
919 apply_script_settings_env(&mut cmd, &settings);
920 }
921
922 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
923 let status = run_command_killing_descendants(cmd, script_name).await?;
924
925 if !status.success() {
926 return Err(Error::NonZeroExit {
927 script: script_name.to_string(),
928 code: status.code(),
929 });
930 }
931
932 Ok(())
933}
934
935pub async fn run_root_hook(
941 project_dir: &Path,
942 modules_dir_name: &str,
943 manifest: &PackageJson,
944 hook: LifecycleHook,
945) -> Result<bool, Error> {
946 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
947}
948
949pub async fn run_root_script_by_name(
956 project_dir: &Path,
957 modules_dir_name: &str,
958 manifest: &PackageJson,
959 name: &str,
960) -> Result<bool, Error> {
961 let Some(script_cmd) = manifest.scripts.get(name) else {
962 return Ok(false);
963 };
964 run_script(
965 project_dir,
966 project_dir,
967 modules_dir_name,
968 manifest,
969 name,
970 script_cmd,
971 &[],
972 None,
973 )
974 .await?;
975 Ok(true)
976}
977
978pub fn implicit_install_script(
991 manifest: &PackageJson,
992 has_binding_gyp: bool,
993) -> Option<&'static str> {
994 if !has_binding_gyp {
995 return None;
996 }
997 if manifest
998 .scripts
999 .contains_key(LifecycleHook::Install.script_name())
1000 || manifest
1001 .scripts
1002 .contains_key(LifecycleHook::PreInstall.script_name())
1003 {
1004 return None;
1005 }
1006 Some("node-gyp rebuild")
1007}
1008
1009pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
1013 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
1014}
1015
1016pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
1021 if DEP_LIFECYCLE_HOOKS
1022 .iter()
1023 .any(|h| manifest.scripts.contains_key(h.script_name()))
1024 {
1025 return true;
1026 }
1027 default_install_script(package_dir, manifest).is_some()
1028}
1029
1030#[allow(clippy::too_many_arguments)]
1060pub async fn run_dep_hook(
1061 package_dir: &Path,
1062 dep_modules_dir: &Path,
1063 project_root: &Path,
1064 modules_dir_name: &str,
1065 manifest: &PackageJson,
1066 hook: LifecycleHook,
1067 tool_bin_dirs: &[&Path],
1068 jail: Option<&ScriptJail>,
1069) -> Result<bool, Error> {
1070 let name = hook.script_name();
1071 let script_cmd: &str = match manifest.scripts.get(name) {
1072 Some(s) => s.as_str(),
1073 None => match hook {
1074 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1075 Some(s) => s,
1076 None => return Ok(false),
1077 },
1078 _ => return Ok(false),
1079 },
1080 };
1081 let dep_bin_dir = dep_modules_dir.join(".bin");
1082 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1083 bin_dirs.push(&dep_bin_dir);
1084 bin_dirs.extend(tool_bin_dirs.iter().copied());
1085 run_script(
1086 package_dir,
1087 project_root,
1088 modules_dir_name,
1089 manifest,
1090 name,
1091 script_cmd,
1092 &bin_dirs,
1093 jail,
1094 )
1095 .await?;
1096 Ok(true)
1097}
1098
1099#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1100pub enum Error {
1101 #[error("failed to spawn script {0}: {1}")]
1102 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1103 Spawn(String, String),
1104 #[error("script `{script}` exited with code {code:?}")]
1105 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1106 NonZeroExit { script: String, code: Option<i32> },
1107}
1108
1109#[cfg(test)]
1110mod user_agent_tests {
1111 use super::*;
1112
1113 #[test]
1114 fn user_agent_uses_node_style_platform_and_arch() {
1115 let ua = aube_user_agent();
1116 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1118 let parts: Vec<&str> = ua.split(' ').collect();
1119 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1120 let platform = parts[1];
1122 assert!(
1123 matches!(
1124 platform,
1125 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1126 ),
1127 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1128 );
1129 let arch = parts[2];
1133 assert!(
1134 matches!(
1135 arch,
1136 "x64"
1137 | "arm64"
1138 | "ia32"
1139 | "arm"
1140 | "ppc"
1141 | "ppc64"
1142 | "loong64"
1143 | "mips"
1144 | "riscv64"
1145 | "s390x"
1146 ),
1147 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1148 );
1149 }
1150}
1151
1152#[cfg(test)]
1153mod jail_tests {
1154 use super::*;
1155
1156 #[test]
1157 fn jail_home_uses_full_package_path() {
1158 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1159 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1160
1161 assert_ne!(a, b);
1162 assert!(
1163 a.file_name()
1164 .unwrap()
1165 .to_string_lossy()
1166 .starts_with("native-")
1167 );
1168 assert!(
1169 b.file_name()
1170 .unwrap()
1171 .to_string_lossy()
1172 .starts_with("native-")
1173 );
1174 }
1175
1176 #[test]
1177 fn jail_home_cleanup_removes_temp_home() {
1178 let package_dir = std::env::temp_dir()
1179 .join("aube-jail-cleanup-test")
1180 .join(std::process::id().to_string())
1181 .join("node_modules")
1182 .join("native");
1183 let jail = ScriptJail::new(&package_dir);
1184 let home = jail_home(&package_dir);
1185 std::fs::create_dir_all(home.join(".cache")).unwrap();
1186 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1187
1188 {
1189 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1190 }
1191
1192 assert!(!home.exists());
1193 }
1194
1195 #[test]
1196 fn parent_env_cannot_override_explicit_jail_metadata() {
1197 for key in [
1198 "PATH",
1199 "HOME",
1200 "npm_lifecycle_event",
1201 "npm_package_name",
1202 "npm_package_version",
1203 ] {
1204 assert!(!inherit_jail_env_key(key, &[]));
1205 }
1206 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1207 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1208 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1209 assert!(inherit_jail_env_key(
1210 "SHARP_DIST_BASE_URL",
1211 &["SHARP_DIST_BASE_URL".to_string()]
1212 ));
1213 }
1214
1215 #[test]
1216 fn jail_env_preserves_script_settings_after_clear() {
1217 let mut cmd = tokio::process::Command::new("node");
1218 let manifest = PackageJson {
1219 name: Some("pkg".to_string()),
1220 version: Some("1.2.3".to_string()),
1221 ..Default::default()
1222 };
1223 let settings = ScriptSettings {
1224 node_options: Some("--conditions=aube".to_string()),
1225 unsafe_perm: Some(false),
1226 shell_emulator: true,
1227 ..Default::default()
1228 };
1229
1230 apply_jail_env(
1231 &mut cmd,
1232 std::ffi::OsStr::new("/bin"),
1233 Path::new("/tmp/aube-jail/home"),
1234 Path::new("/tmp/project"),
1235 &manifest,
1236 "postinstall",
1237 &[],
1238 );
1239 apply_script_settings_env(&mut cmd, &settings);
1240
1241 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1242 let env = |name: &str| {
1243 envs.iter()
1244 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1245 .and_then(|(_, val)| *val)
1246 .and_then(|val| val.to_str())
1247 };
1248
1249 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1250 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1251 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1252 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1253 assert_eq!(env("npm_package_name"), Some("pkg"));
1254 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1255 }
1256}
1257
1258#[cfg(all(test, windows))]
1259mod windows_quote_tests {
1260 use super::shell_quote_arg;
1261
1262 #[test]
1263 fn windows_path_backslash_not_doubled() {
1264 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1265 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1266 }
1267
1268 #[test]
1269 fn windows_trailing_backslash_doubled_before_close_quote() {
1270 let q = shell_quote_arg(r"C:\path\");
1271 assert_eq!(q, "\"C:\\path\\\\\"");
1272 }
1273
1274 #[test]
1275 fn windows_quote_in_arg_escapes_with_backslash() {
1276 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1277 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1278 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1279 }
1280}
1281
1282#[cfg(all(test, windows))]
1289mod windows_job_object_tests {
1290 use super::*;
1291 use std::time::{Duration, Instant};
1292 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1293 use windows_sys::Win32::System::Threading::{
1294 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1295 };
1296
1297 fn is_process_alive(pid: u32) -> bool {
1298 unsafe {
1302 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1303 if handle.is_null() {
1304 return false;
1305 }
1306 let mut code: u32 = 0;
1307 let ok = GetExitCodeProcess(handle, &mut code);
1308 CloseHandle(handle);
1309 ok != 0 && code == STILL_ACTIVE as u32
1310 }
1311 }
1312
1313 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1314 let start = Instant::now();
1315 while !check() {
1316 if start.elapsed() > timeout {
1317 return false;
1318 }
1319 tokio::time::sleep(Duration::from_millis(75)).await;
1320 }
1321 true
1322 }
1323
1324 #[tokio::test]
1325 async fn aborting_script_kills_grandchildren() {
1326 let nanos = std::time::SystemTime::now()
1330 .duration_since(std::time::UNIX_EPOCH)
1331 .unwrap_or_default()
1332 .as_nanos();
1333 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1334 let script = format!(
1343 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1344 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1345 & ping -n 10 127.0.0.1 >nul",
1346 pid_file.display()
1347 );
1348 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1349 let task = tokio::spawn(async move {
1350 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1351 });
1352
1353 let appeared = wait_until(
1354 || {
1355 std::fs::read_to_string(&pid_file)
1356 .ok()
1357 .and_then(|pid| pid.trim().parse::<u32>().ok())
1358 .is_some()
1359 },
1360 Duration::from_secs(20),
1361 )
1362 .await;
1363 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1364 let pid: u32 = std::fs::read_to_string(&pid_file)
1365 .expect("read pid file")
1366 .trim()
1367 .parse()
1368 .expect("pid file was parseable before reading");
1369 assert!(
1370 is_process_alive(pid),
1371 "grandchild pid {pid} not alive immediately after writing pid file"
1372 );
1373
1374 task.abort();
1379 let _ = task.await;
1380
1381 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1382 let _ = std::fs::remove_file(&pid_file);
1383 assert!(
1384 reaped,
1385 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1386 );
1387 }
1388}