1pub mod policy;
16
17#[cfg(target_os = "linux")]
18mod linux_jail;
19
20#[cfg(windows)]
21mod windows_job;
22
23pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
24
25use aube_manifest::PackageJson;
26use std::collections::hash_map::DefaultHasher;
27use std::hash::{Hash, Hasher};
28use std::path::{Path, PathBuf};
29
30#[derive(Debug, Clone, Default)]
32pub struct ScriptSettings {
33 pub node_options: Option<String>,
34 pub script_shell: Option<PathBuf>,
35 pub unsafe_perm: Option<bool>,
36 pub shell_emulator: bool,
37}
38
39#[derive(Debug, Clone)]
41pub struct ScriptJail {
42 pub package_dir: PathBuf,
43 pub env: Vec<String>,
44 pub read_paths: Vec<PathBuf>,
45 pub write_paths: Vec<PathBuf>,
46 pub network: bool,
47}
48
49impl ScriptJail {
50 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
51 Self {
52 package_dir: package_dir.into(),
53 env: Vec::new(),
54 read_paths: Vec::new(),
55 write_paths: Vec::new(),
56 network: false,
57 }
58 }
59
60 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
61 self.env = env.into_iter().collect();
62 self
63 }
64
65 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
66 self.read_paths = paths.into_iter().collect();
67 self
68 }
69
70 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
71 self.write_paths = paths.into_iter().collect();
72 self
73 }
74
75 pub fn with_network(mut self, network: bool) -> Self {
76 self.network = network;
77 self
78 }
79}
80
81pub struct ScriptJailHomeCleanup {
82 path: PathBuf,
83}
84
85impl ScriptJailHomeCleanup {
86 pub fn new(jail: &ScriptJail) -> Self {
87 Self {
88 path: jail_home(&jail.package_dir),
89 }
90 }
91}
92
93impl Drop for ScriptJailHomeCleanup {
94 fn drop(&mut self) {
95 if self.path.exists()
96 && let Err(err) = std::fs::remove_dir_all(&self.path)
97 {
98 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
99 }
100 }
101}
102
103static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
104 std::sync::OnceLock::new();
105
106fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
107 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
108}
109
110pub fn set_script_settings(settings: ScriptSettings) {
114 match script_settings_lock().write() {
115 Ok(mut guard) => *guard = settings,
116 Err(poisoned) => *poisoned.into_inner() = settings,
117 }
118}
119
120fn script_settings() -> ScriptSettings {
121 match script_settings_lock().read() {
122 Ok(guard) => guard.clone(),
123 Err(poisoned) => poisoned.into_inner().clone(),
124 }
125}
126
127pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
130 let path = std::env::var_os("PATH").unwrap_or_default();
131 let mut entries = vec![bin_dir.to_path_buf()];
132 entries.extend(std::env::split_paths(&path));
133 std::env::join_paths(entries).unwrap_or(path)
134}
135
136pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
155 let settings = script_settings();
156 spawn_shell_with_settings(script_cmd, &settings)
157}
158
159fn spawn_shell_with_settings(
160 script_cmd: &str,
161 settings: &ScriptSettings,
162) -> tokio::process::Command {
163 #[cfg(unix)]
164 let mut cmd = {
165 let mut cmd = tokio::process::Command::new(
166 settings
167 .script_shell
168 .as_deref()
169 .unwrap_or_else(|| Path::new("sh")),
170 );
171 cmd.arg("-c").arg(script_cmd);
172 cmd
173 };
174 #[cfg(windows)]
175 let mut cmd = {
176 let mut cmd = tokio::process::Command::new(
177 settings
178 .script_shell
179 .as_deref()
180 .unwrap_or_else(|| Path::new("cmd.exe")),
181 );
182 if settings.script_shell.is_some() {
183 cmd.arg("-c").arg(script_cmd);
184 } else {
185 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
190 }
191 cmd
192 };
193 apply_script_settings_env(&mut cmd, settings);
194 cmd.kill_on_drop(true);
203 cmd
204}
205
206#[cfg(target_os = "macos")]
207fn sbpl_escape(s: &str) -> String {
208 s.replace('\\', "\\\\").replace('"', "\\\"")
209}
210
211#[cfg(target_os = "macos")]
212fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
213 let path = sbpl_escape(&path.to_string_lossy());
214 let rule = format!("(allow file-write* (subpath \"{path}\"))");
215 if !rules.iter().any(|existing| existing == &rule) {
216 rules.push(rule);
217 }
218}
219
220#[cfg(target_os = "macos")]
221fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
222 let mut rules = vec![
223 "(version 1)".to_string(),
224 "(allow default)".to_string(),
225 "(allow network* (local unix))".to_string(),
226 "(deny file-write*)".to_string(),
227 ];
228 if !jail.network {
229 rules.insert(2, "(deny network*)".to_string());
230 }
231
232 for path in [
233 Path::new("/tmp"),
234 Path::new("/private/tmp"),
235 Path::new("/dev"),
236 ] {
237 push_write_rule(&mut rules, path);
238 }
239 for path in [&jail.package_dir, home] {
240 push_write_rule(&mut rules, path);
241 }
242 for path in &jail.write_paths {
243 push_write_rule(&mut rules, path);
244 }
245 for path in [&jail.package_dir, home] {
246 if let Ok(canonical) = path.canonicalize() {
247 push_write_rule(&mut rules, &canonical);
248 }
249 }
250 for path in &jail.write_paths {
251 if let Ok(canonical) = path.canonicalize() {
252 push_write_rule(&mut rules, &canonical);
253 }
254 }
255 rules.join("\n")
256}
257
258#[cfg(target_os = "macos")]
259fn spawn_jailed_shell(
260 script_cmd: &str,
261 settings: &ScriptSettings,
262 jail: &ScriptJail,
263 home: &Path,
264) -> tokio::process::Command {
265 let shell = settings
266 .script_shell
267 .as_deref()
268 .unwrap_or_else(|| Path::new("sh"));
269 let profile = jail_profile(jail, home);
270 let mut cmd = tokio::process::Command::new("sandbox-exec");
271 cmd.arg("-p")
272 .arg(profile)
273 .arg("--")
274 .arg(shell)
275 .arg("-c")
276 .arg(script_cmd);
277 apply_script_settings_env(&mut cmd, settings);
278 cmd.kill_on_drop(true);
280 cmd
281}
282
283#[cfg(target_os = "linux")]
284fn spawn_jailed_shell(
285 script_cmd: &str,
286 settings: &ScriptSettings,
287 jail: &ScriptJail,
288 home: &Path,
289) -> tokio::process::Command {
290 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
291 let jail = jail.clone();
292 let home = home.to_path_buf();
293 unsafe {
294 cmd.pre_exec(move || {
295 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
296 if !jail.network {
297 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
298 }
299 Ok(())
300 });
301 }
302 cmd
303}
304
305#[cfg(not(any(target_os = "linux", target_os = "macos")))]
306fn spawn_jailed_shell(
307 script_cmd: &str,
308 settings: &ScriptSettings,
309 _jail: &ScriptJail,
310 _home: &Path,
311) -> tokio::process::Command {
312 spawn_shell_with_settings(script_cmd, settings)
313}
314
315pub fn shell_quote_arg(arg: &str) -> String {
338 #[cfg(unix)]
339 {
340 let mut out = String::with_capacity(arg.len() + 2);
341 out.push('\'');
342 for ch in arg.chars() {
343 if ch == '\'' {
344 out.push_str("'\\''");
345 } else {
346 out.push(ch);
347 }
348 }
349 out.push('\'');
350 out
351 }
352 #[cfg(windows)]
353 {
354 let mut out = String::with_capacity(arg.len() + 2);
355 out.push('"');
356 let mut backslashes: usize = 0;
357 for ch in arg.chars() {
358 match ch {
359 '\\' => backslashes += 1,
360 '"' => {
361 for _ in 0..backslashes * 2 + 1 {
362 out.push('\\');
363 }
364 out.push('"');
365 backslashes = 0;
366 }
367 '%' => {
378 for _ in 0..backslashes {
379 out.push('\\');
380 }
381 backslashes = 0;
382 out.push_str("%%");
383 }
384 _ => {
385 for _ in 0..backslashes {
386 out.push('\\');
387 }
388 backslashes = 0;
389 out.push(ch);
390 }
391 }
392 }
393 for _ in 0..backslashes * 2 {
394 out.push('\\');
395 }
396 out.push('"');
397 out
398 }
399}
400
401pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
413 if let Some(code) = status.code() {
414 return code;
415 }
416 #[cfg(unix)]
417 {
418 use std::os::unix::process::ExitStatusExt;
419 if let Some(sig) = status.signal() {
420 return 128 + sig;
421 }
422 }
423 1
424}
425
426pub fn aube_user_agent() -> String {
436 format!(
437 "aube/{} {} {}",
438 env!("CARGO_PKG_VERSION"),
439 node_platform(),
440 node_arch(),
441 )
442}
443
444fn node_platform() -> &'static str {
445 match std::env::consts::OS {
446 "macos" => "darwin",
447 "windows" => "win32",
448 other => other,
449 }
450}
451
452fn node_arch() -> &'static str {
453 match std::env::consts::ARCH {
460 "x86_64" => "x64",
461 "aarch64" => "arm64",
462 "x86" => "ia32",
463 "powerpc" => "ppc",
464 "powerpc64" => "ppc64",
465 "loongarch64" => "loong64",
466 other => other,
467 }
468}
469
470fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
471 cmd.env_remove("AUBE_AUTH_TOKEN");
478 cmd.env("npm_config_user_agent", aube_user_agent());
483 if let Some(node_options) = settings.node_options.as_deref() {
484 cmd.env("NODE_OPTIONS", node_options);
485 }
486 if let Some(unsafe_perm) = settings.unsafe_perm {
487 cmd.env(
488 "npm_config_unsafe_perm",
489 if unsafe_perm { "true" } else { "false" },
490 );
491 }
492 if settings.shell_emulator {
493 cmd.env("npm_config_shell_emulator", "true");
494 }
495}
496
497fn safe_jail_env_key(key: &str) -> bool {
498 const EXACT: &[&str] = &[
499 "PATH",
500 "HOME",
501 "TERM",
502 "LANG",
503 "LC_ALL",
504 "INIT_CWD",
505 "npm_lifecycle_event",
506 "npm_package_name",
507 "npm_package_version",
508 ];
509 if EXACT.contains(&key) {
510 return true;
511 }
512 let lower = key.to_ascii_lowercase();
513 if lower.contains("token")
514 || lower.contains("auth")
515 || lower.contains("password")
516 || lower.contains("credential")
517 || lower.contains("secret")
518 {
519 return false;
520 }
521 key.starts_with("npm_config_")
522}
523
524fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
525 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
526 && !matches!(
527 key,
528 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
529 )
530}
531
532fn jail_home(package_dir: &Path) -> PathBuf {
533 let mut hasher = DefaultHasher::new();
534 package_dir.hash(&mut hasher);
535 let hash = hasher.finish();
536 let name = package_dir
537 .file_name()
538 .and_then(|s| s.to_str())
539 .unwrap_or("package")
540 .chars()
541 .map(|c| {
542 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
543 c
544 } else {
545 '_'
546 }
547 })
548 .collect::<String>();
549 std::env::temp_dir()
550 .join("aube-jail")
551 .join(std::process::id().to_string())
552 .join(format!("{name}-{hash:016x}"))
553}
554
555fn apply_jail_env(
556 cmd: &mut tokio::process::Command,
557 path_env: &std::ffi::OsStr,
558 home: &Path,
559 project_root: &Path,
560 manifest: &PackageJson,
561 script_name: &str,
562 extra_env: &[String],
563) {
564 cmd.env_clear();
565 cmd.env("PATH", path_env)
566 .env("HOME", home)
567 .env("TMPDIR", home)
568 .env("TMP", home)
569 .env("TEMP", home)
570 .env("npm_lifecycle_event", script_name);
571 if std::env::var_os("INIT_CWD").is_none() {
572 cmd.env("INIT_CWD", project_root);
573 }
574 if let Some(ref name) = manifest.name {
575 cmd.env("npm_package_name", name);
576 }
577 if let Some(ref version) = manifest.version {
578 cmd.env("npm_package_version", version);
579 }
580 for (key, val) in std::env::vars_os() {
581 let Some(key_str) = key.to_str() else {
582 continue;
583 };
584 if inherit_jail_env_key(key_str, extra_env) {
585 cmd.env(key, val);
586 }
587 }
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub enum LifecycleHook {
595 PreInstall,
596 Install,
597 PostInstall,
598 Prepare,
599}
600
601impl LifecycleHook {
602 pub fn script_name(self) -> &'static str {
603 match self {
604 Self::PreInstall => "preinstall",
605 Self::Install => "install",
606 Self::PostInstall => "postinstall",
607 Self::Prepare => "prepare",
608 }
609 }
610}
611
612pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
616 LifecycleHook::PreInstall,
617 LifecycleHook::Install,
618 LifecycleHook::PostInstall,
619];
620
621#[cfg(unix)]
629static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
630
631#[cfg(unix)]
636pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
637 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
638}
639
640#[cfg(not(unix))]
645pub fn set_saved_stderr_fd(_fd: i32) {}
646
647#[cfg(unix)]
652pub fn child_stderr() -> std::process::Stdio {
653 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
654 if fd < 0 {
655 return std::process::Stdio::inherit();
656 }
657 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
662 match borrowed.try_clone_to_owned() {
663 Ok(owned) => std::process::Stdio::from(owned),
664 Err(_) => std::process::Stdio::inherit(),
665 }
666}
667
668#[cfg(not(unix))]
669pub fn child_stderr() -> std::process::Stdio {
670 std::process::Stdio::inherit()
671}
672
673#[cfg(unix)]
689pub fn write_line_to_real_stderr(line: &str) {
690 use std::io::Write;
691 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
692 let fd = if saved >= 0 { saved } else { 2 };
693 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
700 let Ok(owned) = borrowed.try_clone_to_owned() else {
701 return;
702 };
703 let mut file = std::fs::File::from(owned);
704 let mut buf = String::with_capacity(line.len() + 1);
705 buf.push_str(line);
706 buf.push('\n');
707 let _ = file.write_all(buf.as_bytes());
708}
709
710#[cfg(not(unix))]
711pub fn write_line_to_real_stderr(line: &str) {
712 eprintln!("{line}");
713}
714
715async fn run_command_killing_descendants(
751 mut cmd: tokio::process::Command,
752 script_name: &str,
753) -> Result<std::process::ExitStatus, Error> {
754 let mut child = cmd
755 .spawn()
756 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
757 #[cfg(windows)]
758 let _job = match windows_job::JobObject::new() {
759 Ok(job) => {
760 if let Some(handle) = child.raw_handle()
764 && let Err(err) = job.assign(handle)
765 {
766 tracing::warn!(
773 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
774 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
775 grandchildren may be orphaned if the script is aborted"
776 );
777 }
778 Some(job)
779 }
780 Err(err) => {
781 tracing::warn!(
782 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
783 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
784 running without orphan-reaping — grandchildren may leak if aborted"
785 );
786 None
787 }
788 };
789 child
790 .wait()
791 .await
792 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
793}
794
795#[allow(clippy::too_many_arguments)]
812pub async fn run_script(
813 script_dir: &Path,
814 project_root: &Path,
815 modules_dir_name: &str,
816 manifest: &PackageJson,
817 script_name: &str,
818 script_cmd: &str,
819 extra_bin_dirs: &[&Path],
820 jail: Option<&ScriptJail>,
821) -> Result<(), Error> {
822 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
827 .with_meta_fn(|| {
828 let pkg = manifest.name.as_deref().unwrap_or("(root)");
829 format!(
830 r#"{{"pkg":{},"script":{}}}"#,
831 aube_util::diag::jstr(pkg),
832 aube_util::diag::jstr(script_name)
833 )
834 });
835 let project_bin = project_root.join(modules_dir_name).join(".bin");
843 let path = std::env::var_os("PATH").unwrap_or_default();
844 let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
845 for dir in extra_bin_dirs {
846 entries.push(dir.to_path_buf());
847 }
848 entries.push(project_bin);
849 entries.extend(std::env::split_paths(&path));
850 let new_path = std::env::join_paths(entries).unwrap_or(path);
851
852 let settings = script_settings();
853 let jail_home = jail.map(|j| jail_home(&j.package_dir));
854 if let Some(home) = &jail_home {
855 std::fs::create_dir_all(home)
856 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
857 }
858 let mut cmd = match (jail, jail_home.as_deref()) {
859 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
860 _ => spawn_shell_with_settings(script_cmd, &settings),
861 };
862 cmd.current_dir(script_dir)
863 .stderr(child_stderr())
864 .env("PATH", &new_path)
865 .env("npm_lifecycle_event", script_name);
866
867 if std::env::var_os("INIT_CWD").is_none() {
874 cmd.env("INIT_CWD", project_root);
875 }
876
877 if let Some(ref name) = manifest.name {
878 cmd.env("npm_package_name", name);
879 }
880 if let Some(ref version) = manifest.version {
881 cmd.env("npm_package_version", version);
882 }
883 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
884 apply_jail_env(
885 &mut cmd,
886 &new_path,
887 home,
888 project_root,
889 manifest,
890 script_name,
891 &jail.env,
892 );
893 apply_script_settings_env(&mut cmd, &settings);
894 }
895
896 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
897 let status = run_command_killing_descendants(cmd, script_name).await?;
898
899 if !status.success() {
900 return Err(Error::NonZeroExit {
901 script: script_name.to_string(),
902 code: status.code(),
903 });
904 }
905
906 Ok(())
907}
908
909pub async fn run_root_hook(
915 project_dir: &Path,
916 modules_dir_name: &str,
917 manifest: &PackageJson,
918 hook: LifecycleHook,
919) -> Result<bool, Error> {
920 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
921}
922
923pub async fn run_root_script_by_name(
930 project_dir: &Path,
931 modules_dir_name: &str,
932 manifest: &PackageJson,
933 name: &str,
934) -> Result<bool, Error> {
935 let Some(script_cmd) = manifest.scripts.get(name) else {
936 return Ok(false);
937 };
938 run_script(
939 project_dir,
940 project_dir,
941 modules_dir_name,
942 manifest,
943 name,
944 script_cmd,
945 &[],
946 None,
947 )
948 .await?;
949 Ok(true)
950}
951
952pub fn implicit_install_script(
965 manifest: &PackageJson,
966 has_binding_gyp: bool,
967) -> Option<&'static str> {
968 if !has_binding_gyp {
969 return None;
970 }
971 if manifest
972 .scripts
973 .contains_key(LifecycleHook::Install.script_name())
974 || manifest
975 .scripts
976 .contains_key(LifecycleHook::PreInstall.script_name())
977 {
978 return None;
979 }
980 Some("node-gyp rebuild")
981}
982
983pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
987 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
988}
989
990pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
995 if DEP_LIFECYCLE_HOOKS
996 .iter()
997 .any(|h| manifest.scripts.contains_key(h.script_name()))
998 {
999 return true;
1000 }
1001 default_install_script(package_dir, manifest).is_some()
1002}
1003
1004#[allow(clippy::too_many_arguments)]
1034pub async fn run_dep_hook(
1035 package_dir: &Path,
1036 dep_modules_dir: &Path,
1037 project_root: &Path,
1038 modules_dir_name: &str,
1039 manifest: &PackageJson,
1040 hook: LifecycleHook,
1041 tool_bin_dirs: &[&Path],
1042 jail: Option<&ScriptJail>,
1043) -> Result<bool, Error> {
1044 let name = hook.script_name();
1045 let script_cmd: &str = match manifest.scripts.get(name) {
1046 Some(s) => s.as_str(),
1047 None => match hook {
1048 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1049 Some(s) => s,
1050 None => return Ok(false),
1051 },
1052 _ => return Ok(false),
1053 },
1054 };
1055 let dep_bin_dir = dep_modules_dir.join(".bin");
1056 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1057 bin_dirs.push(&dep_bin_dir);
1058 bin_dirs.extend(tool_bin_dirs.iter().copied());
1059 run_script(
1060 package_dir,
1061 project_root,
1062 modules_dir_name,
1063 manifest,
1064 name,
1065 script_cmd,
1066 &bin_dirs,
1067 jail,
1068 )
1069 .await?;
1070 Ok(true)
1071}
1072
1073#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1074pub enum Error {
1075 #[error("failed to spawn script {0}: {1}")]
1076 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1077 Spawn(String, String),
1078 #[error("script `{script}` exited with code {code:?}")]
1079 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1080 NonZeroExit { script: String, code: Option<i32> },
1081}
1082
1083#[cfg(test)]
1084mod user_agent_tests {
1085 use super::*;
1086
1087 #[test]
1088 fn user_agent_uses_node_style_platform_and_arch() {
1089 let ua = aube_user_agent();
1090 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1092 let parts: Vec<&str> = ua.split(' ').collect();
1093 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1094 let platform = parts[1];
1096 assert!(
1097 matches!(
1098 platform,
1099 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1100 ),
1101 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1102 );
1103 let arch = parts[2];
1107 assert!(
1108 matches!(
1109 arch,
1110 "x64"
1111 | "arm64"
1112 | "ia32"
1113 | "arm"
1114 | "ppc"
1115 | "ppc64"
1116 | "loong64"
1117 | "mips"
1118 | "riscv64"
1119 | "s390x"
1120 ),
1121 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1122 );
1123 }
1124}
1125
1126#[cfg(test)]
1127mod jail_tests {
1128 use super::*;
1129
1130 #[test]
1131 fn jail_home_uses_full_package_path() {
1132 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1133 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1134
1135 assert_ne!(a, b);
1136 assert!(
1137 a.file_name()
1138 .unwrap()
1139 .to_string_lossy()
1140 .starts_with("native-")
1141 );
1142 assert!(
1143 b.file_name()
1144 .unwrap()
1145 .to_string_lossy()
1146 .starts_with("native-")
1147 );
1148 }
1149
1150 #[test]
1151 fn jail_home_cleanup_removes_temp_home() {
1152 let package_dir = std::env::temp_dir()
1153 .join("aube-jail-cleanup-test")
1154 .join(std::process::id().to_string())
1155 .join("node_modules")
1156 .join("native");
1157 let jail = ScriptJail::new(&package_dir);
1158 let home = jail_home(&package_dir);
1159 std::fs::create_dir_all(home.join(".cache")).unwrap();
1160 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1161
1162 {
1163 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1164 }
1165
1166 assert!(!home.exists());
1167 }
1168
1169 #[test]
1170 fn parent_env_cannot_override_explicit_jail_metadata() {
1171 for key in [
1172 "PATH",
1173 "HOME",
1174 "npm_lifecycle_event",
1175 "npm_package_name",
1176 "npm_package_version",
1177 ] {
1178 assert!(!inherit_jail_env_key(key, &[]));
1179 }
1180 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1181 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1182 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1183 assert!(inherit_jail_env_key(
1184 "SHARP_DIST_BASE_URL",
1185 &["SHARP_DIST_BASE_URL".to_string()]
1186 ));
1187 }
1188
1189 #[test]
1190 fn jail_env_preserves_script_settings_after_clear() {
1191 let mut cmd = tokio::process::Command::new("node");
1192 let manifest = PackageJson {
1193 name: Some("pkg".to_string()),
1194 version: Some("1.2.3".to_string()),
1195 ..Default::default()
1196 };
1197 let settings = ScriptSettings {
1198 node_options: Some("--conditions=aube".to_string()),
1199 unsafe_perm: Some(false),
1200 shell_emulator: true,
1201 ..Default::default()
1202 };
1203
1204 apply_jail_env(
1205 &mut cmd,
1206 std::ffi::OsStr::new("/bin"),
1207 Path::new("/tmp/aube-jail/home"),
1208 Path::new("/tmp/project"),
1209 &manifest,
1210 "postinstall",
1211 &[],
1212 );
1213 apply_script_settings_env(&mut cmd, &settings);
1214
1215 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1216 let env = |name: &str| {
1217 envs.iter()
1218 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1219 .and_then(|(_, val)| *val)
1220 .and_then(|val| val.to_str())
1221 };
1222
1223 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1224 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1225 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1226 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1227 assert_eq!(env("npm_package_name"), Some("pkg"));
1228 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1229 }
1230}
1231
1232#[cfg(all(test, windows))]
1233mod windows_quote_tests {
1234 use super::shell_quote_arg;
1235
1236 #[test]
1237 fn windows_path_backslash_not_doubled() {
1238 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1239 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1240 }
1241
1242 #[test]
1243 fn windows_trailing_backslash_doubled_before_close_quote() {
1244 let q = shell_quote_arg(r"C:\path\");
1245 assert_eq!(q, "\"C:\\path\\\\\"");
1246 }
1247
1248 #[test]
1249 fn windows_quote_in_arg_escapes_with_backslash() {
1250 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1251 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1252 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1253 }
1254}
1255
1256#[cfg(all(test, windows))]
1263mod windows_job_object_tests {
1264 use super::*;
1265 use std::time::{Duration, Instant};
1266 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1267 use windows_sys::Win32::System::Threading::{
1268 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1269 };
1270
1271 fn is_process_alive(pid: u32) -> bool {
1272 unsafe {
1276 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1277 if handle.is_null() {
1278 return false;
1279 }
1280 let mut code: u32 = 0;
1281 let ok = GetExitCodeProcess(handle, &mut code);
1282 CloseHandle(handle);
1283 ok != 0 && code == STILL_ACTIVE as u32
1284 }
1285 }
1286
1287 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1288 let start = Instant::now();
1289 while !check() {
1290 if start.elapsed() > timeout {
1291 return false;
1292 }
1293 tokio::time::sleep(Duration::from_millis(75)).await;
1294 }
1295 true
1296 }
1297
1298 #[tokio::test]
1299 async fn aborting_script_kills_grandchildren() {
1300 let nanos = std::time::SystemTime::now()
1304 .duration_since(std::time::UNIX_EPOCH)
1305 .unwrap_or_default()
1306 .as_nanos();
1307 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1308 let script = format!(
1317 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1318 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1319 & ping -n 10 127.0.0.1 >nul",
1320 pid_file.display()
1321 );
1322 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1323 let task = tokio::spawn(async move {
1324 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1325 });
1326
1327 let appeared = wait_until(|| pid_file.exists(), Duration::from_secs(20)).await;
1328 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1329 let pid: u32 = std::fs::read_to_string(&pid_file)
1330 .expect("read pid file")
1331 .trim()
1332 .parse()
1333 .expect("parse pid");
1334 assert!(
1335 is_process_alive(pid),
1336 "grandchild pid {pid} not alive immediately after writing pid file"
1337 );
1338
1339 task.abort();
1344 let _ = task.await;
1345
1346 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1347 let _ = std::fs::remove_file(&pid_file);
1348 assert!(
1349 reaped,
1350 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1351 );
1352 }
1353}