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