1pub mod content_sniff;
16pub mod direct;
17pub mod policy;
18
19#[cfg(target_os = "linux")]
20mod linux_jail;
21
22#[cfg(windows)]
23mod windows_job;
24
25pub use content_sniff::{Suspicion, SuspicionKind, sniff_lifecycle};
26pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
27
28use aube_manifest::PackageJson;
29use std::collections::hash_map::DefaultHasher;
30use std::hash::{Hash, Hasher};
31use std::path::{Path, PathBuf};
32use tokio::io::{AsyncBufReadExt, AsyncReadExt};
33
34const MAX_SCRIPT_OUTPUT_RECORD_BYTES: usize = 64 * 1024;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ScriptOutputStream {
38 Stdout,
39 Stderr,
40}
41
42pub trait ScriptOutputReporter: Send + Sync + 'static {
43 fn report(&self, stream: ScriptOutputStream, line: String);
44}
45
46#[derive(Debug, Clone, Default)]
48pub struct ScriptSettings {
49 pub node_options: Option<String>,
50 pub script_shell: Option<PathBuf>,
51 pub unsafe_perm: Option<bool>,
52 pub shell_emulator: bool,
53 pub node_bin_dir: Option<PathBuf>,
56 pub node_program: Option<PathBuf>,
60 pub node_execpath: Option<PathBuf>,
64 pub extra_env: Vec<(std::ffi::OsString, std::ffi::OsString)>,
70 pub command: Option<String>,
75 pub node_gyp_js: Option<PathBuf>,
82 pub http_proxy: Option<String>,
91 pub https_proxy: Option<String>,
92 pub no_proxy: Option<String>,
93}
94
95#[derive(Debug, Clone)]
97pub struct ScriptJail {
98 pub package_dir: PathBuf,
99 pub env: Vec<String>,
100 pub read_paths: Vec<PathBuf>,
101 pub write_paths: Vec<PathBuf>,
102 pub network: bool,
103}
104
105impl ScriptJail {
106 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
107 Self {
108 package_dir: package_dir.into(),
109 env: Vec::new(),
110 read_paths: Vec::new(),
111 write_paths: Vec::new(),
112 network: false,
113 }
114 }
115
116 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
117 self.env = env.into_iter().collect();
118 self
119 }
120
121 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
122 self.read_paths = paths.into_iter().collect();
123 self
124 }
125
126 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
127 self.write_paths = paths.into_iter().collect();
128 self
129 }
130
131 pub fn with_network(mut self, network: bool) -> Self {
132 self.network = network;
133 self
134 }
135}
136
137pub struct ScriptJailHomeCleanup {
138 path: PathBuf,
139}
140
141impl ScriptJailHomeCleanup {
142 pub fn new(jail: &ScriptJail) -> Self {
143 Self {
144 path: jail_home(&jail.package_dir),
145 }
146 }
147}
148
149impl Drop for ScriptJailHomeCleanup {
150 fn drop(&mut self) {
151 if self.path.exists()
152 && let Err(err) = std::fs::remove_dir_all(&self.path)
153 {
154 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
155 }
156 }
157}
158
159#[derive(Clone, Default)]
160struct ScriptSettingsState {
161 settings: ScriptSettings,
162 node_bin_dir_precedes_project_bins: bool,
163 output_reporter: Option<std::sync::Arc<dyn ScriptOutputReporter>>,
164}
165
166static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettingsState>> =
167 std::sync::OnceLock::new();
168
169type ScriptSettingsSlot = std::sync::Arc<std::sync::RwLock<ScriptSettingsState>>;
170
171tokio::task_local! {
172 static INSTALL_SCRIPT_SETTINGS: ScriptSettingsSlot;
173}
174
175pub async fn scope<F: std::future::Future>(future: F) -> F::Output {
177 INSTALL_SCRIPT_SETTINGS
178 .scope(
179 std::sync::Arc::new(std::sync::RwLock::new(ScriptSettingsState::default())),
180 future,
181 )
182 .await
183}
184
185pub fn scope_current<F: std::future::Future>(
187 future: F,
188) -> impl std::future::Future<Output = F::Output> {
189 let settings = INSTALL_SCRIPT_SETTINGS.try_with(std::sync::Arc::clone).ok();
190 async move {
191 match settings {
192 Some(settings) => INSTALL_SCRIPT_SETTINGS.scope(settings, future).await,
193 None => future.await,
194 }
195 }
196}
197
198fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettingsState> {
199 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettingsState::default()))
200}
201
202pub fn set_script_settings(settings: ScriptSettings) {
205 set_script_settings_with_path_order(settings, false);
206}
207
208#[doc(hidden)]
212pub fn set_script_settings_with_path_order(
213 settings: ScriptSettings,
214 node_bin_dir_precedes_project_bins: bool,
215) {
216 if INSTALL_SCRIPT_SETTINGS
217 .try_with(|slot| match slot.write() {
218 Ok(mut guard) => {
219 guard.settings = settings.clone();
220 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
221 }
222 Err(poisoned) => {
223 let mut guard = poisoned.into_inner();
224 guard.settings = settings.clone();
225 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
226 }
227 })
228 .is_ok()
229 {
230 return;
231 }
232 match script_settings_lock().write() {
233 Ok(mut guard) => {
234 guard.settings = settings;
235 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
236 }
237 Err(poisoned) => {
238 let mut guard = poisoned.into_inner();
239 guard.settings = settings;
240 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
241 }
242 }
243}
244
245pub fn set_output_reporter(reporter: Option<std::sync::Arc<dyn ScriptOutputReporter>>) {
248 if INSTALL_SCRIPT_SETTINGS
249 .try_with(|slot| match slot.write() {
250 Ok(mut guard) => guard.output_reporter = reporter.clone(),
251 Err(poisoned) => poisoned.into_inner().output_reporter = reporter.clone(),
252 })
253 .is_ok()
254 {
255 return;
256 }
257 match script_settings_lock().write() {
258 Ok(mut guard) => guard.output_reporter = reporter,
259 Err(poisoned) => poisoned.into_inner().output_reporter = reporter,
260 }
261}
262
263fn script_settings_state() -> ScriptSettingsState {
264 if let Ok(state) = INSTALL_SCRIPT_SETTINGS.try_with(|slot| match slot.read() {
265 Ok(guard) => guard.clone(),
266 Err(poisoned) => poisoned.into_inner().clone(),
267 }) {
268 return state;
269 }
270 match script_settings_lock().read() {
271 Ok(guard) => guard.clone(),
272 Err(poisoned) => poisoned.into_inner().clone(),
273 }
274}
275
276fn script_settings() -> ScriptSettings {
277 script_settings_state().settings
278}
279
280#[cfg(test)]
281mod scoped_settings_tests {
282 use super::*;
283
284 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
285 async fn install_script_settings_are_isolated_and_propagated() {
286 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
287 let first_barrier = std::sync::Arc::clone(&barrier);
288 let second_barrier = std::sync::Arc::clone(&barrier);
289
290 let first = scope(async move {
291 set_script_settings_with_path_order(
292 ScriptSettings {
293 command: Some("first".to_string()),
294 ..ScriptSettings::default()
295 },
296 true,
297 );
298 first_barrier.wait().await;
299 tokio::spawn(scope_current(async {
300 let state = script_settings_state();
301 (
302 state.settings.command,
303 state.node_bin_dir_precedes_project_bins,
304 )
305 }))
306 .await
307 .unwrap()
308 });
309 let second = scope(async move {
310 set_script_settings(ScriptSettings {
311 command: Some("second".to_string()),
312 ..ScriptSettings::default()
313 });
314 second_barrier.wait().await;
315 tokio::spawn(scope_current(async {
316 let state = script_settings_state();
317 (
318 state.settings.command,
319 state.node_bin_dir_precedes_project_bins,
320 )
321 }))
322 .await
323 .unwrap()
324 });
325
326 let (first, second) = tokio::join!(first, second);
327 assert_eq!(first.0.as_deref(), Some("first"));
328 assert!(first.1);
329 assert_eq!(second.0.as_deref(), Some("second"));
330 assert!(!second.1);
331 }
332}
333
334pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
337 prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf()))
338}
339
340pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString {
342 let path = std::env::var_os("PATH").unwrap_or_default();
343 let mut entries: Vec<PathBuf> = bin_dirs.to_vec();
344 entries.extend(std::env::split_paths(&path));
345 std::env::join_paths(entries).unwrap_or(path)
346}
347
348pub fn order_path_entries(
352 mut project_bins: Vec<PathBuf>,
353 runtime_bin: Option<&Path>,
354 runtime_precedes_project_bins: bool,
355) -> Vec<PathBuf> {
356 let Some(runtime_bin) = runtime_bin else {
357 return project_bins;
358 };
359 if runtime_precedes_project_bins {
360 project_bins.insert(0, runtime_bin.to_path_buf());
361 } else {
362 project_bins.push(runtime_bin.to_path_buf());
363 }
364 project_bins
365}
366
367#[cfg(test)]
368mod path_entry_tests {
369 use super::*;
370
371 #[test]
372 fn wrapper_runtime_leads_project_bins() {
373 let runtime = Path::new("/shim");
374 let project = PathBuf::from("/project/node_modules/.bin");
375 assert_eq!(
376 order_path_entries(vec![project.clone()], Some(runtime), true),
377 vec![runtime.to_path_buf(), project]
378 );
379 }
380
381 #[test]
382 fn selector_runtime_follows_project_bins() {
383 let runtime = Path::new("/opt/node/bin");
384 let project = PathBuf::from("/project/node_modules/.bin");
385 assert_eq!(
386 order_path_entries(vec![project.clone()], Some(runtime), false),
387 vec![project, runtime.to_path_buf()]
388 );
389 }
390}
391
392pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
411 let settings = script_settings();
412 spawn_shell_with_settings(script_cmd, &settings)
413}
414
415pub fn spawn_program<I, S>(program: &Path, arg0: &str, args: I) -> tokio::process::Command
430where
431 I: IntoIterator<Item = S>,
432 S: AsRef<std::ffi::OsStr>,
433{
434 let settings = script_settings();
435 let mut cmd = tokio::process::Command::new(program);
436 #[cfg(unix)]
437 {
438 use std::os::unix::process::CommandExt;
439 cmd.as_std_mut().arg0(arg0);
440 }
441 #[cfg(not(unix))]
442 let _ = arg0;
443 cmd.args(args);
444 apply_script_settings_env(&mut cmd, &settings);
445 cmd.kill_on_drop(true);
446 cmd
447}
448
449fn spawn_shell_with_settings(
450 script_cmd: &str,
451 settings: &ScriptSettings,
452) -> tokio::process::Command {
453 #[cfg(unix)]
454 let mut cmd = {
455 let mut cmd = tokio::process::Command::new(
456 settings
457 .script_shell
458 .as_deref()
459 .unwrap_or_else(|| Path::new("sh")),
460 );
461 cmd.arg("-c").arg(script_cmd);
462 cmd
463 };
464 #[cfg(windows)]
465 let mut cmd = {
466 let mut cmd = tokio::process::Command::new(
467 settings
468 .script_shell
469 .as_deref()
470 .unwrap_or_else(|| Path::new("cmd.exe")),
471 );
472 if settings.script_shell.is_some() {
473 cmd.arg("-c").arg(script_cmd);
474 } else {
475 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
480 }
481 cmd
482 };
483 apply_script_settings_env(&mut cmd, settings);
484 cmd.kill_on_drop(true);
493 cmd
494}
495
496#[cfg(target_os = "macos")]
497fn sbpl_escape(s: &str) -> String {
498 s.replace('\\', "\\\\").replace('"', "\\\"")
499}
500
501#[cfg(target_os = "macos")]
502fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
503 let path = sbpl_escape(&path.to_string_lossy());
504 let rule = format!("(allow file-write* (subpath \"{path}\"))");
505 if !rules.iter().any(|existing| existing == &rule) {
506 rules.push(rule);
507 }
508}
509
510#[cfg(target_os = "macos")]
511fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
512 let mut rules = vec![
513 "(version 1)".to_string(),
514 "(allow default)".to_string(),
515 "(allow network* (local unix))".to_string(),
516 "(deny file-write*)".to_string(),
517 ];
518 if !jail.network {
519 rules.insert(2, "(deny network*)".to_string());
520 }
521
522 for path in [
523 Path::new("/tmp"),
524 Path::new("/private/tmp"),
525 Path::new("/dev"),
526 ] {
527 push_write_rule(&mut rules, path);
528 }
529 for path in [&jail.package_dir, home] {
530 push_write_rule(&mut rules, path);
531 }
532 for path in &jail.write_paths {
533 push_write_rule(&mut rules, path);
534 }
535 for path in [&jail.package_dir, home] {
536 if let Ok(canonical) = path.canonicalize() {
537 push_write_rule(&mut rules, &canonical);
538 }
539 }
540 for path in &jail.write_paths {
541 if let Ok(canonical) = path.canonicalize() {
542 push_write_rule(&mut rules, &canonical);
543 }
544 }
545 rules.join("\n")
546}
547
548#[cfg(target_os = "macos")]
549fn spawn_jailed_shell(
550 script_cmd: &str,
551 settings: &ScriptSettings,
552 jail: &ScriptJail,
553 home: &Path,
554) -> tokio::process::Command {
555 let shell = settings
556 .script_shell
557 .as_deref()
558 .unwrap_or_else(|| Path::new("sh"));
559 let profile = jail_profile(jail, home);
560 let mut cmd = tokio::process::Command::new("sandbox-exec");
561 cmd.arg("-p")
562 .arg(profile)
563 .arg("--")
564 .arg(shell)
565 .arg("-c")
566 .arg(script_cmd);
567 apply_script_settings_env(&mut cmd, settings);
568 cmd.kill_on_drop(true);
570 cmd
571}
572
573#[cfg(target_os = "linux")]
574fn spawn_jailed_shell(
575 script_cmd: &str,
576 settings: &ScriptSettings,
577 jail: &ScriptJail,
578 home: &Path,
579) -> tokio::process::Command {
580 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
581 let jail = jail.clone();
582 let home = home.to_path_buf();
583 unsafe {
584 cmd.pre_exec(move || {
585 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
586 if !jail.network {
587 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
588 }
589 Ok(())
590 });
591 }
592 cmd
593}
594
595#[cfg(not(any(target_os = "linux", target_os = "macos")))]
596fn spawn_jailed_shell(
597 script_cmd: &str,
598 settings: &ScriptSettings,
599 _jail: &ScriptJail,
600 _home: &Path,
601) -> tokio::process::Command {
602 spawn_shell_with_settings(script_cmd, settings)
603}
604
605pub fn shell_quote_arg(arg: &str) -> String {
628 #[cfg(unix)]
629 {
630 let mut out = String::with_capacity(arg.len() + 2);
631 out.push('\'');
632 for ch in arg.chars() {
633 if ch == '\'' {
634 out.push_str("'\\''");
635 } else {
636 out.push(ch);
637 }
638 }
639 out.push('\'');
640 out
641 }
642 #[cfg(windows)]
643 {
644 let mut out = String::with_capacity(arg.len() + 2);
645 out.push('"');
646 let mut backslashes: usize = 0;
647 for ch in arg.chars() {
648 match ch {
649 '\\' => backslashes += 1,
650 '"' => {
651 for _ in 0..backslashes * 2 + 1 {
652 out.push('\\');
653 }
654 out.push('"');
655 backslashes = 0;
656 }
657 '%' => {
668 for _ in 0..backslashes {
669 out.push('\\');
670 }
671 backslashes = 0;
672 out.push_str("%%");
673 }
674 _ => {
675 for _ in 0..backslashes {
676 out.push('\\');
677 }
678 backslashes = 0;
679 out.push(ch);
680 }
681 }
682 }
683 for _ in 0..backslashes * 2 {
684 out.push('\\');
685 }
686 out.push('"');
687 out
688 }
689}
690
691pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
703 if let Some(code) = status.code() {
704 return code;
705 }
706 #[cfg(unix)]
707 {
708 use std::os::unix::process::ExitStatusExt;
709 if let Some(sig) = status.signal() {
710 return 128 + sig;
711 }
712 }
713 1
714}
715
716pub fn aube_user_agent() -> String {
726 format!(
727 "{} {} {}",
728 aube_util::embedder().user_agent,
729 node_platform(),
730 node_arch(),
731 )
732}
733
734fn node_platform() -> &'static str {
735 match std::env::consts::OS {
736 "macos" => "darwin",
737 "windows" => "win32",
738 other => other,
739 }
740}
741
742fn node_arch() -> &'static str {
743 match std::env::consts::ARCH {
750 "x86_64" => "x64",
751 "aarch64" => "arm64",
752 "x86" => "ia32",
753 "powerpc" => "ppc",
754 "powerpc64" => "ppc64",
755 "loongarch64" => "loong64",
756 other => other,
757 }
758}
759
760fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
761 cmd.env_remove("AUBE_AUTH_TOKEN");
768 cmd.env("npm_config_user_agent", aube_user_agent());
773 let aube_exe = std::env::current_exe().ok();
779 if let Some(exe) = aube_exe.as_deref() {
780 cmd.env("npm_execpath", exe);
781 }
782 let node_execpath = settings
789 .node_execpath
790 .as_deref()
791 .or(settings.node_program.as_deref());
792 if let Some(execpath) = node_execpath {
793 cmd.env("npm_node_execpath", execpath);
794 }
795 if let Some(node) = settings.node_program.as_deref().or(node_execpath) {
796 cmd.env("NODE", node);
797 }
798 if let Some(command) = settings.command.as_deref() {
800 cmd.env("npm_command", command);
801 }
802 if let Some(node_gyp_js) = settings.node_gyp_js.as_deref() {
813 cmd.env("npm_config_node_gyp", node_gyp_js);
814 if let Some(exe) = aube_exe.as_deref() {
815 cmd.env("AUBE_NODE_GYP_EXE", exe);
816 }
817 }
818 if let Some(node_options) = settings.node_options.as_deref() {
819 cmd.env("NODE_OPTIONS", node_options);
820 }
821 if let Some(unsafe_perm) = settings.unsafe_perm {
822 cmd.env(
823 "npm_config_unsafe_perm",
824 if unsafe_perm { "true" } else { "false" },
825 );
826 }
827 if settings.shell_emulator {
828 cmd.env("npm_config_shell_emulator", "true");
829 }
830 if settings.http_proxy.is_some() || settings.https_proxy.is_some() {
845 if let Some(https) = settings.https_proxy.as_deref() {
846 cmd.env("HTTPS_PROXY", https);
847 }
848 if let Some(http) = settings.http_proxy.as_deref() {
849 cmd.env("HTTP_PROXY", http);
850 }
851 if let Some(no_proxy) = settings.no_proxy.as_deref() {
852 cmd.env("NO_PROXY", no_proxy);
853 }
854 cmd.env("NODE_USE_ENV_PROXY", "1");
855 }
856 for (key, value) in &settings.extra_env {
862 cmd.env(key, value);
863 }
864}
865
866pub fn apply_npm_manifest_env(
885 cmd: &mut tokio::process::Command,
886 manifest: &PackageJson,
887 script_dir: &Path,
888 lifecycle_script: &str,
889) {
890 for (key, _) in std::env::vars_os() {
891 if key.to_str().is_some_and(|k| k.starts_with("npm_package_")) {
892 cmd.env_remove(&key);
893 }
894 }
895 cmd.env("npm_lifecycle_script", lifecycle_script);
896 cmd.env("npm_package_json", script_dir.join("package.json"));
897 for (key, value) in manifest.npm_package_env() {
898 cmd.env(key, value);
899 }
900}
901
902fn safe_jail_env_key(key: &str) -> bool {
903 const EXACT: &[&str] = &[
904 "PATH",
905 "HOME",
906 "TERM",
907 "LANG",
908 "LC_ALL",
909 "INIT_CWD",
910 "npm_lifecycle_event",
911 "npm_package_name",
912 "npm_package_version",
913 ];
914 if EXACT.contains(&key) {
915 return true;
916 }
917 let lower = key.to_ascii_lowercase();
918 if lower.contains("token")
919 || lower.contains("auth")
920 || lower.contains("password")
921 || lower.contains("credential")
922 || lower.contains("secret")
923 {
924 return false;
925 }
926 key.starts_with("npm_config_")
927}
928
929fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
930 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
931 && !matches!(
932 key,
933 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
934 )
935}
936
937fn jail_home(package_dir: &Path) -> PathBuf {
938 let mut hasher = DefaultHasher::new();
939 package_dir.hash(&mut hasher);
940 let hash = hasher.finish();
941 let name = package_dir
942 .file_name()
943 .and_then(|s| s.to_str())
944 .unwrap_or("package")
945 .chars()
946 .map(|c| {
947 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
948 c
949 } else {
950 '_'
951 }
952 })
953 .collect::<String>();
954 std::env::temp_dir()
955 .join("aube-jail")
956 .join(std::process::id().to_string())
957 .join(format!("{name}-{hash:016x}"))
958}
959
960fn apply_jail_env(
961 cmd: &mut tokio::process::Command,
962 path_env: &std::ffi::OsStr,
963 home: &Path,
964 project_root: &Path,
965 manifest: &PackageJson,
966 script_name: &str,
967 extra_env: &[String],
968) {
969 cmd.env_clear();
970 cmd.env("PATH", path_env)
971 .env("HOME", home)
972 .env("TMPDIR", home)
973 .env("TMP", home)
974 .env("TEMP", home)
975 .env("AUBE_NODE_GYP_PROJECT_DIR", project_root)
981 .env("npm_lifecycle_event", script_name);
982 if std::env::var_os("INIT_CWD").is_none() {
983 cmd.env("INIT_CWD", project_root);
984 }
985 if let Some(ref name) = manifest.name {
986 cmd.env("npm_package_name", name);
987 }
988 if let Some(ref version) = manifest.version {
989 cmd.env("npm_package_version", version);
990 }
991 for (key, val) in std::env::vars_os() {
992 let Some(key_str) = key.to_str() else {
993 continue;
994 };
995 if inherit_jail_env_key(key_str, extra_env) {
996 cmd.env(key, val);
997 }
998 }
999}
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1005pub enum LifecycleHook {
1006 PreInstall,
1007 Install,
1008 PostInstall,
1009 Prepare,
1010}
1011
1012impl LifecycleHook {
1013 pub fn script_name(self) -> &'static str {
1014 match self {
1015 Self::PreInstall => "preinstall",
1016 Self::Install => "install",
1017 Self::PostInstall => "postinstall",
1018 Self::Prepare => "prepare",
1019 }
1020 }
1021}
1022
1023pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
1027 LifecycleHook::PreInstall,
1028 LifecycleHook::Install,
1029 LifecycleHook::PostInstall,
1030];
1031
1032#[cfg(unix)]
1040static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
1041
1042#[cfg(unix)]
1047pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
1048 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
1049}
1050
1051#[cfg(not(unix))]
1056pub fn set_saved_stderr_fd(_fd: i32) {}
1057
1058#[cfg(unix)]
1063pub fn child_stderr() -> std::process::Stdio {
1064 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1065 if fd < 0 {
1066 return std::process::Stdio::inherit();
1067 }
1068 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1073 match borrowed.try_clone_to_owned() {
1074 Ok(owned) => std::process::Stdio::from(owned),
1075 Err(_) => std::process::Stdio::inherit(),
1076 }
1077}
1078
1079#[cfg(not(unix))]
1080pub fn child_stderr() -> std::process::Stdio {
1081 std::process::Stdio::inherit()
1082}
1083
1084#[cfg(unix)]
1100pub fn write_line_to_real_stderr(line: &str) {
1101 use std::io::Write;
1102 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1103 let fd = if saved >= 0 { saved } else { 2 };
1104 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1111 let Ok(owned) = borrowed.try_clone_to_owned() else {
1112 return;
1113 };
1114 let mut file = std::fs::File::from(owned);
1115 let mut buf = String::with_capacity(line.len() + 1);
1116 buf.push_str(line);
1117 buf.push('\n');
1118 let _ = file.write_all(buf.as_bytes());
1119}
1120
1121#[cfg(not(unix))]
1122pub fn write_line_to_real_stderr(line: &str) {
1123 eprintln!("{line}");
1124}
1125
1126async fn run_command_killing_descendants(
1162 mut cmd: tokio::process::Command,
1163 script_name: &str,
1164) -> Result<std::process::ExitStatus, Error> {
1165 let output_reporter = script_settings_state().output_reporter;
1166 if output_reporter.is_some() {
1167 cmd.stdout(std::process::Stdio::piped())
1168 .stderr(std::process::Stdio::piped());
1169 }
1170 let mut child = cmd
1171 .spawn()
1172 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1173 #[cfg(windows)]
1174 let _job = match windows_job::JobObject::new() {
1175 Ok(job) => {
1176 if let Some(handle) = child.raw_handle()
1180 && let Err(err) = job.assign(handle)
1181 {
1182 tracing::warn!(
1189 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1190 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
1191 grandchildren may be orphaned if the script is aborted"
1192 );
1193 }
1194 Some(job)
1195 }
1196 Err(err) => {
1197 tracing::warn!(
1198 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1199 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
1200 running without orphan-reaping — grandchildren may leak if aborted"
1201 );
1202 None
1203 }
1204 };
1205 let Some(reporter) = output_reporter else {
1206 return child
1207 .wait()
1208 .await
1209 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()));
1210 };
1211 let stdout = child.stdout.take().ok_or_else(|| {
1212 Error::Spawn(
1213 script_name.to_string(),
1214 "failed to capture lifecycle stdout".to_string(),
1215 )
1216 })?;
1217 let stderr = child.stderr.take().ok_or_else(|| {
1218 Error::Spawn(
1219 script_name.to_string(),
1220 "failed to capture lifecycle stderr".to_string(),
1221 )
1222 })?;
1223 let (status, stdout_result, stderr_result) = tokio::join!(
1224 child.wait(),
1225 report_script_output(stdout, ScriptOutputStream::Stdout, reporter.clone()),
1226 report_script_output(stderr, ScriptOutputStream::Stderr, reporter),
1227 );
1228 stdout_result.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1229 stderr_result.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1230 status.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
1231}
1232
1233async fn report_script_output<R: tokio::io::AsyncRead + Unpin>(
1234 reader: R,
1235 stream: ScriptOutputStream,
1236 reporter: std::sync::Arc<dyn ScriptOutputReporter>,
1237) -> std::io::Result<()> {
1238 let mut reader = tokio::io::BufReader::new(reader);
1239 let mut buffer = Vec::new();
1240 let mut continued_record = false;
1241 loop {
1242 buffer.clear();
1243 let mut limited = (&mut reader).take(MAX_SCRIPT_OUTPUT_RECORD_BYTES as u64);
1244 if limited.read_until(b'\n', &mut buffer).await? == 0 {
1245 return Ok(());
1246 }
1247 let record_terminated = buffer.last() == Some(&b'\n');
1248 if record_terminated {
1249 buffer.pop();
1250 if buffer.last() == Some(&b'\r') {
1251 buffer.pop();
1252 }
1253 }
1254 if !(continued_record && record_terminated && buffer.is_empty()) {
1255 reporter.report(stream, String::from_utf8_lossy(&buffer).into_owned());
1256 }
1257 continued_record = !record_terminated;
1258 }
1259}
1260
1261#[cfg(test)]
1262mod script_output_tests {
1263 use super::*;
1264 use tokio::io::AsyncWriteExt;
1265
1266 #[derive(Default)]
1267 struct RecordingReporter(std::sync::Mutex<Vec<String>>);
1268
1269 impl ScriptOutputReporter for RecordingReporter {
1270 fn report(&self, _stream: ScriptOutputStream, line: String) {
1271 self.0.lock().unwrap().push(line);
1272 }
1273 }
1274
1275 #[tokio::test]
1276 async fn unterminated_output_is_reported_in_bounded_chunks() {
1277 let reporter = std::sync::Arc::new(RecordingReporter::default());
1278 let (mut writer, reader) = tokio::io::duplex(1024);
1279 let mut output = vec![b'x'; MAX_SCRIPT_OUTPUT_RECORD_BYTES * 2 + 17];
1280 output.extend_from_slice(b"\nnext\n");
1281 let write = tokio::spawn(async move {
1282 writer.write_all(&output).await.unwrap();
1283 });
1284
1285 report_script_output(reader, ScriptOutputStream::Stdout, reporter.clone())
1286 .await
1287 .unwrap();
1288 write.await.unwrap();
1289
1290 let messages = reporter.0.lock().unwrap();
1291 assert_eq!(
1292 messages.iter().map(String::len).collect::<Vec<_>>(),
1293 [
1294 MAX_SCRIPT_OUTPUT_RECORD_BYTES,
1295 MAX_SCRIPT_OUTPUT_RECORD_BYTES,
1296 17,
1297 4,
1298 ]
1299 );
1300 assert_eq!(messages.last().map(String::as_str), Some("next"));
1301 }
1302}
1303
1304#[allow(clippy::too_many_arguments)]
1322pub async fn run_script(
1323 script_dir: &Path,
1324 project_root: &Path,
1325 modules_dir_name: &str,
1326 manifest: &PackageJson,
1327 script_name: &str,
1328 script_cmd: &str,
1329 extra_bin_dirs: &[&Path],
1330 jail: Option<&ScriptJail>,
1331) -> Result<(), Error> {
1332 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
1337 .with_meta_fn(|| {
1338 let pkg = manifest.name.as_deref().unwrap_or("(root)");
1339 format!(
1340 r#"{{"pkg":{},"script":{}}}"#,
1341 aube_util::diag::jstr(pkg),
1342 aube_util::diag::jstr(script_name)
1343 )
1344 });
1345 let project_bin = project_root.join(modules_dir_name).join(".bin");
1353 let state = script_settings_state();
1354 let settings = &state.settings;
1355 let path = std::env::var_os("PATH").unwrap_or_default();
1356 let mut project_bins: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
1357 for dir in extra_bin_dirs {
1358 project_bins.push(dir.to_path_buf());
1359 }
1360 project_bins.push(project_bin);
1361 let mut entries = order_path_entries(
1362 project_bins,
1363 settings.node_bin_dir.as_deref(),
1364 state.node_bin_dir_precedes_project_bins,
1365 );
1366 entries.extend(std::env::split_paths(&path));
1367 let new_path = std::env::join_paths(entries).unwrap_or(path);
1368 let jail_home = jail.map(|j| jail_home(&j.package_dir));
1369 if let Some(home) = &jail_home {
1370 std::fs::create_dir_all(home)
1371 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1372 }
1373 let mut cmd = match (jail, jail_home.as_deref()) {
1374 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, settings, jail, home),
1375 _ => spawn_shell_with_settings(script_cmd, settings),
1376 };
1377 cmd.current_dir(script_dir)
1378 .stderr(child_stderr())
1379 .env("PATH", &new_path)
1380 .env("npm_lifecycle_event", script_name);
1381
1382 if std::env::var_os("INIT_CWD").is_none() {
1389 cmd.env("INIT_CWD", project_root);
1390 }
1391
1392 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
1393 apply_jail_env(
1394 &mut cmd,
1395 &new_path,
1396 home,
1397 project_root,
1398 manifest,
1399 script_name,
1400 &jail.env,
1401 );
1402 apply_script_settings_env(&mut cmd, settings);
1403 } else {
1404 cmd.env("AUBE_NODE_GYP_PROJECT_DIR", project_root);
1408 }
1409
1410 apply_npm_manifest_env(&mut cmd, manifest, script_dir, script_cmd);
1414
1415 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
1416 let status = run_command_killing_descendants(cmd, script_name).await?;
1417
1418 if !status.success() {
1419 return Err(Error::NonZeroExit {
1420 script: script_name.to_string(),
1421 code: status.code(),
1422 });
1423 }
1424
1425 Ok(())
1426}
1427
1428pub async fn run_root_hook(
1434 project_dir: &Path,
1435 modules_dir_name: &str,
1436 manifest: &PackageJson,
1437 hook: LifecycleHook,
1438) -> Result<bool, Error> {
1439 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
1440}
1441
1442pub async fn run_root_script_by_name(
1449 project_dir: &Path,
1450 modules_dir_name: &str,
1451 manifest: &PackageJson,
1452 name: &str,
1453) -> Result<bool, Error> {
1454 let Some(script_cmd) = manifest.scripts.get(name) else {
1455 return Ok(false);
1456 };
1457 run_script(
1458 project_dir,
1459 project_dir,
1460 modules_dir_name,
1461 manifest,
1462 name,
1463 script_cmd,
1464 &[],
1465 None,
1466 )
1467 .await?;
1468 Ok(true)
1469}
1470
1471pub fn implicit_install_script(
1484 manifest: &PackageJson,
1485 has_binding_gyp: bool,
1486) -> Option<&'static str> {
1487 if !has_binding_gyp {
1488 return None;
1489 }
1490 if manifest
1491 .scripts
1492 .contains_key(LifecycleHook::Install.script_name())
1493 || manifest
1494 .scripts
1495 .contains_key(LifecycleHook::PreInstall.script_name())
1496 {
1497 return None;
1498 }
1499 Some("node-gyp rebuild")
1500}
1501
1502pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
1506 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
1507}
1508
1509pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
1514 if DEP_LIFECYCLE_HOOKS
1515 .iter()
1516 .any(|h| manifest.scripts.contains_key(h.script_name()))
1517 {
1518 return true;
1519 }
1520 default_install_script(package_dir, manifest).is_some()
1521}
1522
1523#[allow(clippy::too_many_arguments)]
1553pub async fn run_dep_hook(
1554 package_dir: &Path,
1555 dep_modules_dir: &Path,
1556 project_root: &Path,
1557 modules_dir_name: &str,
1558 manifest: &PackageJson,
1559 hook: LifecycleHook,
1560 tool_bin_dirs: &[&Path],
1561 jail: Option<&ScriptJail>,
1562) -> Result<bool, Error> {
1563 let name = hook.script_name();
1564 let script_cmd: &str = match manifest.scripts.get(name) {
1565 Some(s) => s.as_str(),
1566 None => match hook {
1567 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1568 Some(s) => s,
1569 None => return Ok(false),
1570 },
1571 _ => return Ok(false),
1572 },
1573 };
1574 let dep_bin_dir = dep_modules_dir.join(".bin");
1575 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1576 bin_dirs.push(&dep_bin_dir);
1577 bin_dirs.extend(tool_bin_dirs.iter().copied());
1578 run_script(
1579 package_dir,
1580 project_root,
1581 modules_dir_name,
1582 manifest,
1583 name,
1584 script_cmd,
1585 &bin_dirs,
1586 jail,
1587 )
1588 .await?;
1589 Ok(true)
1590}
1591
1592#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1593pub enum Error {
1594 #[error("failed to spawn script {0}: {1}")]
1595 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1596 Spawn(String, String),
1597 #[error("script `{script}` exited with code {code:?}")]
1598 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1599 NonZeroExit { script: String, code: Option<i32> },
1600}
1601
1602#[cfg(test)]
1603mod user_agent_tests {
1604 use super::*;
1605
1606 #[test]
1607 fn user_agent_uses_node_style_platform_and_arch() {
1608 let ua = aube_user_agent();
1609 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1611 let parts: Vec<&str> = ua.split(' ').collect();
1612 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1613 let platform = parts[1];
1615 assert!(
1616 matches!(
1617 platform,
1618 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1619 ),
1620 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1621 );
1622 let arch = parts[2];
1626 assert!(
1627 matches!(
1628 arch,
1629 "x64"
1630 | "arm64"
1631 | "ia32"
1632 | "arm"
1633 | "ppc"
1634 | "ppc64"
1635 | "loong64"
1636 | "mips"
1637 | "riscv64"
1638 | "s390x"
1639 ),
1640 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1641 );
1642 }
1643}
1644
1645#[cfg(test)]
1646mod spawn_program_tests {
1647 use super::*;
1648
1649 fn env_keys(cmd: &tokio::process::Command) -> Vec<String> {
1650 let mut keys: Vec<String> = cmd
1651 .as_std()
1652 .get_envs()
1653 .map(|(k, _)| k.to_string_lossy().into_owned())
1654 .collect();
1655 keys.sort();
1656 keys
1657 }
1658
1659 #[tokio::test]
1665 async fn spawn_program_stamps_the_same_env_as_spawn_shell() {
1666 let settings = ScriptSettings {
1667 node_options: Some("--max-old-space-size=100".to_string()),
1668 node_program: Some(PathBuf::from("/usr/bin/node")),
1669 node_execpath: Some(PathBuf::from("/usr/bin/node")),
1670 command: Some("run-script".to_string()),
1671 http_proxy: Some("http://proxy.invalid".to_string()),
1672 https_proxy: Some("http://proxy.invalid".to_string()),
1673 ..ScriptSettings::default()
1674 };
1675 scope(async move {
1676 set_script_settings(settings);
1677 let shell = spawn_shell("tool --flag");
1678 let direct = spawn_program(Path::new("/usr/bin/tool"), "tool", ["--flag"]);
1679 assert_eq!(
1680 env_keys(&shell),
1681 env_keys(&direct),
1682 "direct exec must export the same env keys as `sh -c`"
1683 );
1684 })
1685 .await;
1686 }
1687
1688 #[cfg(unix)]
1689 #[tokio::test]
1690 async fn spawn_program_runs_the_program_with_its_args() {
1691 let mut cmd = spawn_program(Path::new("/bin/echo"), "echo", ["a b", "$HOME"]);
1692 let out = cmd.output().await.unwrap();
1693 assert!(out.status.success());
1694 assert_eq!(String::from_utf8_lossy(&out.stdout), "a b $HOME\n");
1696 }
1697}
1698
1699#[cfg(test)]
1700mod jail_tests {
1701 use super::*;
1702
1703 #[test]
1704 fn jail_home_uses_full_package_path() {
1705 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1706 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1707
1708 assert_ne!(a, b);
1709 assert!(
1710 a.file_name()
1711 .unwrap()
1712 .to_string_lossy()
1713 .starts_with("native-")
1714 );
1715 assert!(
1716 b.file_name()
1717 .unwrap()
1718 .to_string_lossy()
1719 .starts_with("native-")
1720 );
1721 }
1722
1723 #[test]
1724 fn jail_home_cleanup_removes_temp_home() {
1725 let package_dir = std::env::temp_dir()
1726 .join("aube-jail-cleanup-test")
1727 .join(std::process::id().to_string())
1728 .join("node_modules")
1729 .join("native");
1730 let jail = ScriptJail::new(&package_dir);
1731 let home = jail_home(&package_dir);
1732 std::fs::create_dir_all(home.join(".cache")).unwrap();
1733 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1734
1735 {
1736 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1737 }
1738
1739 assert!(!home.exists());
1740 }
1741
1742 #[test]
1743 fn parent_env_cannot_override_explicit_jail_metadata() {
1744 for key in [
1745 "PATH",
1746 "HOME",
1747 "npm_lifecycle_event",
1748 "npm_package_name",
1749 "npm_package_version",
1750 ] {
1751 assert!(!inherit_jail_env_key(key, &[]));
1752 }
1753 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1754 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1755 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1756 assert!(inherit_jail_env_key(
1757 "SHARP_DIST_BASE_URL",
1758 &["SHARP_DIST_BASE_URL".to_string()]
1759 ));
1760 }
1761
1762 #[test]
1763 fn jail_env_preserves_script_settings_after_clear() {
1764 let mut cmd = tokio::process::Command::new("node");
1765 let manifest = PackageJson {
1766 name: Some("pkg".to_string()),
1767 version: Some("1.2.3".to_string()),
1768 ..Default::default()
1769 };
1770 let settings = ScriptSettings {
1771 node_options: Some("--conditions=aube".to_string()),
1772 unsafe_perm: Some(false),
1773 shell_emulator: true,
1774 ..Default::default()
1775 };
1776
1777 apply_jail_env(
1778 &mut cmd,
1779 std::ffi::OsStr::new("/bin"),
1780 Path::new("/tmp/aube-jail/home"),
1781 Path::new("/tmp/project"),
1782 &manifest,
1783 "postinstall",
1784 &[],
1785 );
1786 apply_script_settings_env(&mut cmd, &settings);
1787
1788 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1789 let env = |name: &str| {
1790 envs.iter()
1791 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1792 .and_then(|(_, val)| *val)
1793 .and_then(|val| val.to_str())
1794 };
1795
1796 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1797 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1798 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1799 assert_eq!(env("AUBE_NODE_GYP_PROJECT_DIR"), Some("/tmp/project"));
1800 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1801 assert_eq!(env("npm_package_name"), Some("pkg"));
1802 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1803 }
1804
1805 #[test]
1806 fn embedder_env_overrides_jailed_node_gyp_project_default() {
1807 let mut cmd = tokio::process::Command::new("node");
1808 let settings = ScriptSettings {
1809 extra_env: vec![(
1810 "AUBE_NODE_GYP_PROJECT_DIR".into(),
1811 "/tmp/embedder-project".into(),
1812 )],
1813 ..Default::default()
1814 };
1815
1816 apply_jail_env(
1817 &mut cmd,
1818 std::ffi::OsStr::new("/bin"),
1819 Path::new("/tmp/aube-jail/home"),
1820 Path::new("/tmp/project"),
1821 &PackageJson::default(),
1822 "postinstall",
1823 &[],
1824 );
1825 apply_script_settings_env(&mut cmd, &settings);
1826
1827 let project_dir = cmd
1828 .as_std()
1829 .get_envs()
1830 .find(|(key, _)| *key == std::ffi::OsStr::new("AUBE_NODE_GYP_PROJECT_DIR"))
1831 .and_then(|(_, value)| value)
1832 .and_then(|value| value.to_str());
1833 assert_eq!(project_dir, Some("/tmp/embedder-project"));
1834 }
1835
1836 fn proxy_env(settings: ScriptSettings) -> impl Fn(&str) -> Option<String> {
1837 let mut cmd = tokio::process::Command::new("node");
1838 apply_script_settings_env(&mut cmd, &settings);
1839 let envs: Vec<_> = cmd
1840 .as_std()
1841 .get_envs()
1842 .map(|(k, v)| {
1843 (
1844 k.to_string_lossy().into_owned(),
1845 v.map(|v| v.to_string_lossy().into_owned()),
1846 )
1847 })
1848 .collect();
1849 move |name: &str| {
1850 envs.iter()
1851 .find(|(k, _)| k == name)
1852 .and_then(|(_, v)| v.clone())
1853 }
1854 }
1855
1856 #[test]
1857 fn proxy_vars_stamped_when_proxy_configured() {
1858 let env = proxy_env(ScriptSettings {
1859 https_proxy: Some("http://proxy.example:8080".to_string()),
1860 http_proxy: Some("http://proxy.example:8080".to_string()),
1861 no_proxy: Some("localhost,127.0.0.1".to_string()),
1862 ..Default::default()
1863 });
1864 assert_eq!(
1865 env("HTTPS_PROXY").as_deref(),
1866 Some("http://proxy.example:8080")
1867 );
1868 assert_eq!(
1869 env("HTTP_PROXY").as_deref(),
1870 Some("http://proxy.example:8080")
1871 );
1872 assert_eq!(env("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
1873 assert_eq!(env("NODE_USE_ENV_PROXY").as_deref(), Some("1"));
1876 }
1877
1878 #[test]
1879 fn proxy_block_skipped_when_no_proxy_configured() {
1880 let env = proxy_env(ScriptSettings::default());
1884 assert_eq!(env("HTTPS_PROXY"), None);
1885 assert_eq!(env("HTTP_PROXY"), None);
1886 assert_eq!(env("NO_PROXY"), None);
1887 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1888 }
1889
1890 #[test]
1891 fn no_proxy_alone_does_not_trigger_passthrough() {
1892 let env = proxy_env(ScriptSettings {
1895 no_proxy: Some("example.com".to_string()),
1896 ..Default::default()
1897 });
1898 assert_eq!(env("NO_PROXY"), None);
1899 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1900 }
1901
1902 #[test]
1903 fn wrapper_node_and_execpath_are_stamped_distinctly() {
1904 let env = proxy_env(ScriptSettings {
1908 node_program: Some(PathBuf::from("/shim/node")),
1909 node_execpath: Some(PathBuf::from("/real/node-24.4.1/bin/node")),
1910 extra_env: vec![("MYTOOL_WRAPPED".into(), "1".into())],
1911 ..Default::default()
1912 });
1913 assert_eq!(env("NODE").as_deref(), Some("/shim/node"));
1914 assert_eq!(
1915 env("npm_node_execpath").as_deref(),
1916 Some("/real/node-24.4.1/bin/node")
1917 );
1918 assert_eq!(env("MYTOOL_WRAPPED").as_deref(), Some("1"));
1919 }
1920
1921 #[test]
1922 fn node_execpath_falls_back_to_node_program() {
1923 let env = proxy_env(ScriptSettings {
1925 node_program: Some(PathBuf::from("/opt/node/bin/node")),
1926 ..Default::default()
1927 });
1928 assert_eq!(env("NODE").as_deref(), Some("/opt/node/bin/node"));
1929 assert_eq!(
1930 env("npm_node_execpath").as_deref(),
1931 Some("/opt/node/bin/node")
1932 );
1933 }
1934}
1935
1936#[cfg(all(test, windows))]
1937mod windows_quote_tests {
1938 use super::shell_quote_arg;
1939
1940 #[test]
1941 fn windows_path_backslash_not_doubled() {
1942 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1943 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1944 }
1945
1946 #[test]
1947 fn windows_trailing_backslash_doubled_before_close_quote() {
1948 let q = shell_quote_arg(r"C:\path\");
1949 assert_eq!(q, "\"C:\\path\\\\\"");
1950 }
1951
1952 #[test]
1953 fn windows_quote_in_arg_escapes_with_backslash() {
1954 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1955 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1956 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1957 }
1958}
1959
1960#[cfg(all(test, windows))]
1967mod windows_job_object_tests {
1968 use super::*;
1969 use std::time::{Duration, Instant};
1970 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1971 use windows_sys::Win32::System::Threading::{
1972 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1973 };
1974
1975 fn is_process_alive(pid: u32) -> bool {
1976 unsafe {
1980 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1981 if handle.is_null() {
1982 return false;
1983 }
1984 let mut code: u32 = 0;
1985 let ok = GetExitCodeProcess(handle, &mut code);
1986 CloseHandle(handle);
1987 ok != 0 && code == STILL_ACTIVE as u32
1988 }
1989 }
1990
1991 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1992 let start = Instant::now();
1993 while !check() {
1994 if start.elapsed() > timeout {
1995 return false;
1996 }
1997 tokio::time::sleep(Duration::from_millis(75)).await;
1998 }
1999 true
2000 }
2001
2002 #[tokio::test]
2003 async fn aborting_script_kills_grandchildren() {
2004 let nanos = std::time::SystemTime::now()
2008 .duration_since(std::time::UNIX_EPOCH)
2009 .unwrap_or_default()
2010 .as_nanos();
2011 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
2012 let script = format!(
2021 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
2022 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
2023 & ping -n 10 127.0.0.1 >nul",
2024 pid_file.display()
2025 );
2026 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
2027 let task = tokio::spawn(async move {
2028 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
2029 });
2030
2031 let appeared = wait_until(
2032 || {
2033 std::fs::read_to_string(&pid_file)
2034 .ok()
2035 .and_then(|pid| pid.trim().parse::<u32>().ok())
2036 .is_some()
2037 },
2038 Duration::from_secs(20),
2039 )
2040 .await;
2041 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
2042 let pid: u32 = std::fs::read_to_string(&pid_file)
2043 .expect("read pid file")
2044 .trim()
2045 .parse()
2046 .expect("pid file was parseable before reading");
2047 assert!(
2048 is_process_alive(pid),
2049 "grandchild pid {pid} not alive immediately after writing pid file"
2050 );
2051
2052 task.abort();
2057 let _ = task.await;
2058
2059 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
2060 let _ = std::fs::remove_file(&pid_file);
2061 assert!(
2062 reaped,
2063 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
2064 );
2065 }
2066}