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};
31use tokio::io::{AsyncBufReadExt, AsyncReadExt};
32
33const MAX_SCRIPT_OUTPUT_RECORD_BYTES: usize = 64 * 1024;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ScriptOutputStream {
37 Stdout,
38 Stderr,
39}
40
41pub trait ScriptOutputReporter: Send + Sync + 'static {
42 fn report(&self, stream: ScriptOutputStream, line: String);
43}
44
45#[derive(Debug, Clone, Default)]
47pub struct ScriptSettings {
48 pub node_options: Option<String>,
49 pub script_shell: Option<PathBuf>,
50 pub unsafe_perm: Option<bool>,
51 pub shell_emulator: bool,
52 pub node_bin_dir: Option<PathBuf>,
55 pub node_program: Option<PathBuf>,
59 pub node_execpath: Option<PathBuf>,
63 pub extra_env: Vec<(std::ffi::OsString, std::ffi::OsString)>,
69 pub command: Option<String>,
74 pub node_gyp_js: Option<PathBuf>,
81 pub http_proxy: Option<String>,
90 pub https_proxy: Option<String>,
91 pub no_proxy: Option<String>,
92}
93
94#[derive(Debug, Clone)]
96pub struct ScriptJail {
97 pub package_dir: PathBuf,
98 pub env: Vec<String>,
99 pub read_paths: Vec<PathBuf>,
100 pub write_paths: Vec<PathBuf>,
101 pub network: bool,
102}
103
104impl ScriptJail {
105 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
106 Self {
107 package_dir: package_dir.into(),
108 env: Vec::new(),
109 read_paths: Vec::new(),
110 write_paths: Vec::new(),
111 network: false,
112 }
113 }
114
115 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
116 self.env = env.into_iter().collect();
117 self
118 }
119
120 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
121 self.read_paths = paths.into_iter().collect();
122 self
123 }
124
125 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
126 self.write_paths = paths.into_iter().collect();
127 self
128 }
129
130 pub fn with_network(mut self, network: bool) -> Self {
131 self.network = network;
132 self
133 }
134}
135
136pub struct ScriptJailHomeCleanup {
137 path: PathBuf,
138}
139
140impl ScriptJailHomeCleanup {
141 pub fn new(jail: &ScriptJail) -> Self {
142 Self {
143 path: jail_home(&jail.package_dir),
144 }
145 }
146}
147
148impl Drop for ScriptJailHomeCleanup {
149 fn drop(&mut self) {
150 if self.path.exists()
151 && let Err(err) = std::fs::remove_dir_all(&self.path)
152 {
153 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
154 }
155 }
156}
157
158#[derive(Clone, Default)]
159struct ScriptSettingsState {
160 settings: ScriptSettings,
161 node_bin_dir_precedes_project_bins: bool,
162 output_reporter: Option<std::sync::Arc<dyn ScriptOutputReporter>>,
163}
164
165static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettingsState>> =
166 std::sync::OnceLock::new();
167
168type ScriptSettingsSlot = std::sync::Arc<std::sync::RwLock<ScriptSettingsState>>;
169
170tokio::task_local! {
171 static INSTALL_SCRIPT_SETTINGS: ScriptSettingsSlot;
172}
173
174pub async fn scope<F: std::future::Future>(future: F) -> F::Output {
176 INSTALL_SCRIPT_SETTINGS
177 .scope(
178 std::sync::Arc::new(std::sync::RwLock::new(ScriptSettingsState::default())),
179 future,
180 )
181 .await
182}
183
184pub fn scope_current<F: std::future::Future>(
186 future: F,
187) -> impl std::future::Future<Output = F::Output> {
188 let settings = INSTALL_SCRIPT_SETTINGS.try_with(std::sync::Arc::clone).ok();
189 async move {
190 match settings {
191 Some(settings) => INSTALL_SCRIPT_SETTINGS.scope(settings, future).await,
192 None => future.await,
193 }
194 }
195}
196
197fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettingsState> {
198 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettingsState::default()))
199}
200
201pub fn set_script_settings(settings: ScriptSettings) {
204 set_script_settings_with_path_order(settings, false);
205}
206
207#[doc(hidden)]
211pub fn set_script_settings_with_path_order(
212 settings: ScriptSettings,
213 node_bin_dir_precedes_project_bins: bool,
214) {
215 if INSTALL_SCRIPT_SETTINGS
216 .try_with(|slot| match slot.write() {
217 Ok(mut guard) => {
218 guard.settings = settings.clone();
219 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
220 }
221 Err(poisoned) => {
222 let mut guard = poisoned.into_inner();
223 guard.settings = settings.clone();
224 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
225 }
226 })
227 .is_ok()
228 {
229 return;
230 }
231 match script_settings_lock().write() {
232 Ok(mut guard) => {
233 guard.settings = settings;
234 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
235 }
236 Err(poisoned) => {
237 let mut guard = poisoned.into_inner();
238 guard.settings = settings;
239 guard.node_bin_dir_precedes_project_bins = node_bin_dir_precedes_project_bins;
240 }
241 }
242}
243
244pub fn set_output_reporter(reporter: Option<std::sync::Arc<dyn ScriptOutputReporter>>) {
247 if INSTALL_SCRIPT_SETTINGS
248 .try_with(|slot| match slot.write() {
249 Ok(mut guard) => guard.output_reporter = reporter.clone(),
250 Err(poisoned) => poisoned.into_inner().output_reporter = reporter.clone(),
251 })
252 .is_ok()
253 {
254 return;
255 }
256 match script_settings_lock().write() {
257 Ok(mut guard) => guard.output_reporter = reporter,
258 Err(poisoned) => poisoned.into_inner().output_reporter = reporter,
259 }
260}
261
262fn script_settings_state() -> ScriptSettingsState {
263 if let Ok(state) = INSTALL_SCRIPT_SETTINGS.try_with(|slot| match slot.read() {
264 Ok(guard) => guard.clone(),
265 Err(poisoned) => poisoned.into_inner().clone(),
266 }) {
267 return state;
268 }
269 match script_settings_lock().read() {
270 Ok(guard) => guard.clone(),
271 Err(poisoned) => poisoned.into_inner().clone(),
272 }
273}
274
275fn script_settings() -> ScriptSettings {
276 script_settings_state().settings
277}
278
279#[cfg(test)]
280mod scoped_settings_tests {
281 use super::*;
282
283 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
284 async fn install_script_settings_are_isolated_and_propagated() {
285 let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(2));
286 let first_barrier = std::sync::Arc::clone(&barrier);
287 let second_barrier = std::sync::Arc::clone(&barrier);
288
289 let first = scope(async move {
290 set_script_settings_with_path_order(
291 ScriptSettings {
292 command: Some("first".to_string()),
293 ..ScriptSettings::default()
294 },
295 true,
296 );
297 first_barrier.wait().await;
298 tokio::spawn(scope_current(async {
299 let state = script_settings_state();
300 (
301 state.settings.command,
302 state.node_bin_dir_precedes_project_bins,
303 )
304 }))
305 .await
306 .unwrap()
307 });
308 let second = scope(async move {
309 set_script_settings(ScriptSettings {
310 command: Some("second".to_string()),
311 ..ScriptSettings::default()
312 });
313 second_barrier.wait().await;
314 tokio::spawn(scope_current(async {
315 let state = script_settings_state();
316 (
317 state.settings.command,
318 state.node_bin_dir_precedes_project_bins,
319 )
320 }))
321 .await
322 .unwrap()
323 });
324
325 let (first, second) = tokio::join!(first, second);
326 assert_eq!(first.0.as_deref(), Some("first"));
327 assert!(first.1);
328 assert_eq!(second.0.as_deref(), Some("second"));
329 assert!(!second.1);
330 }
331}
332
333pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
336 prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf()))
337}
338
339pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString {
341 let path = std::env::var_os("PATH").unwrap_or_default();
342 let mut entries: Vec<PathBuf> = bin_dirs.to_vec();
343 entries.extend(std::env::split_paths(&path));
344 std::env::join_paths(entries).unwrap_or(path)
345}
346
347pub fn order_path_entries(
351 mut project_bins: Vec<PathBuf>,
352 runtime_bin: Option<&Path>,
353 runtime_precedes_project_bins: bool,
354) -> Vec<PathBuf> {
355 let Some(runtime_bin) = runtime_bin else {
356 return project_bins;
357 };
358 if runtime_precedes_project_bins {
359 project_bins.insert(0, runtime_bin.to_path_buf());
360 } else {
361 project_bins.push(runtime_bin.to_path_buf());
362 }
363 project_bins
364}
365
366#[cfg(test)]
367mod path_entry_tests {
368 use super::*;
369
370 #[test]
371 fn wrapper_runtime_leads_project_bins() {
372 let runtime = Path::new("/shim");
373 let project = PathBuf::from("/project/node_modules/.bin");
374 assert_eq!(
375 order_path_entries(vec![project.clone()], Some(runtime), true),
376 vec![runtime.to_path_buf(), project]
377 );
378 }
379
380 #[test]
381 fn selector_runtime_follows_project_bins() {
382 let runtime = Path::new("/opt/node/bin");
383 let project = PathBuf::from("/project/node_modules/.bin");
384 assert_eq!(
385 order_path_entries(vec![project.clone()], Some(runtime), false),
386 vec![project, runtime.to_path_buf()]
387 );
388 }
389}
390
391pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
410 let settings = script_settings();
411 spawn_shell_with_settings(script_cmd, &settings)
412}
413
414fn spawn_shell_with_settings(
415 script_cmd: &str,
416 settings: &ScriptSettings,
417) -> tokio::process::Command {
418 #[cfg(unix)]
419 let mut cmd = {
420 let mut cmd = tokio::process::Command::new(
421 settings
422 .script_shell
423 .as_deref()
424 .unwrap_or_else(|| Path::new("sh")),
425 );
426 cmd.arg("-c").arg(script_cmd);
427 cmd
428 };
429 #[cfg(windows)]
430 let mut cmd = {
431 let mut cmd = tokio::process::Command::new(
432 settings
433 .script_shell
434 .as_deref()
435 .unwrap_or_else(|| Path::new("cmd.exe")),
436 );
437 if settings.script_shell.is_some() {
438 cmd.arg("-c").arg(script_cmd);
439 } else {
440 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
445 }
446 cmd
447 };
448 apply_script_settings_env(&mut cmd, settings);
449 cmd.kill_on_drop(true);
458 cmd
459}
460
461#[cfg(target_os = "macos")]
462fn sbpl_escape(s: &str) -> String {
463 s.replace('\\', "\\\\").replace('"', "\\\"")
464}
465
466#[cfg(target_os = "macos")]
467fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
468 let path = sbpl_escape(&path.to_string_lossy());
469 let rule = format!("(allow file-write* (subpath \"{path}\"))");
470 if !rules.iter().any(|existing| existing == &rule) {
471 rules.push(rule);
472 }
473}
474
475#[cfg(target_os = "macos")]
476fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
477 let mut rules = vec![
478 "(version 1)".to_string(),
479 "(allow default)".to_string(),
480 "(allow network* (local unix))".to_string(),
481 "(deny file-write*)".to_string(),
482 ];
483 if !jail.network {
484 rules.insert(2, "(deny network*)".to_string());
485 }
486
487 for path in [
488 Path::new("/tmp"),
489 Path::new("/private/tmp"),
490 Path::new("/dev"),
491 ] {
492 push_write_rule(&mut rules, path);
493 }
494 for path in [&jail.package_dir, home] {
495 push_write_rule(&mut rules, path);
496 }
497 for path in &jail.write_paths {
498 push_write_rule(&mut rules, path);
499 }
500 for path in [&jail.package_dir, home] {
501 if let Ok(canonical) = path.canonicalize() {
502 push_write_rule(&mut rules, &canonical);
503 }
504 }
505 for path in &jail.write_paths {
506 if let Ok(canonical) = path.canonicalize() {
507 push_write_rule(&mut rules, &canonical);
508 }
509 }
510 rules.join("\n")
511}
512
513#[cfg(target_os = "macos")]
514fn spawn_jailed_shell(
515 script_cmd: &str,
516 settings: &ScriptSettings,
517 jail: &ScriptJail,
518 home: &Path,
519) -> tokio::process::Command {
520 let shell = settings
521 .script_shell
522 .as_deref()
523 .unwrap_or_else(|| Path::new("sh"));
524 let profile = jail_profile(jail, home);
525 let mut cmd = tokio::process::Command::new("sandbox-exec");
526 cmd.arg("-p")
527 .arg(profile)
528 .arg("--")
529 .arg(shell)
530 .arg("-c")
531 .arg(script_cmd);
532 apply_script_settings_env(&mut cmd, settings);
533 cmd.kill_on_drop(true);
535 cmd
536}
537
538#[cfg(target_os = "linux")]
539fn spawn_jailed_shell(
540 script_cmd: &str,
541 settings: &ScriptSettings,
542 jail: &ScriptJail,
543 home: &Path,
544) -> tokio::process::Command {
545 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
546 let jail = jail.clone();
547 let home = home.to_path_buf();
548 unsafe {
549 cmd.pre_exec(move || {
550 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
551 if !jail.network {
552 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
553 }
554 Ok(())
555 });
556 }
557 cmd
558}
559
560#[cfg(not(any(target_os = "linux", target_os = "macos")))]
561fn spawn_jailed_shell(
562 script_cmd: &str,
563 settings: &ScriptSettings,
564 _jail: &ScriptJail,
565 _home: &Path,
566) -> tokio::process::Command {
567 spawn_shell_with_settings(script_cmd, settings)
568}
569
570pub fn shell_quote_arg(arg: &str) -> String {
593 #[cfg(unix)]
594 {
595 let mut out = String::with_capacity(arg.len() + 2);
596 out.push('\'');
597 for ch in arg.chars() {
598 if ch == '\'' {
599 out.push_str("'\\''");
600 } else {
601 out.push(ch);
602 }
603 }
604 out.push('\'');
605 out
606 }
607 #[cfg(windows)]
608 {
609 let mut out = String::with_capacity(arg.len() + 2);
610 out.push('"');
611 let mut backslashes: usize = 0;
612 for ch in arg.chars() {
613 match ch {
614 '\\' => backslashes += 1,
615 '"' => {
616 for _ in 0..backslashes * 2 + 1 {
617 out.push('\\');
618 }
619 out.push('"');
620 backslashes = 0;
621 }
622 '%' => {
633 for _ in 0..backslashes {
634 out.push('\\');
635 }
636 backslashes = 0;
637 out.push_str("%%");
638 }
639 _ => {
640 for _ in 0..backslashes {
641 out.push('\\');
642 }
643 backslashes = 0;
644 out.push(ch);
645 }
646 }
647 }
648 for _ in 0..backslashes * 2 {
649 out.push('\\');
650 }
651 out.push('"');
652 out
653 }
654}
655
656pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
668 if let Some(code) = status.code() {
669 return code;
670 }
671 #[cfg(unix)]
672 {
673 use std::os::unix::process::ExitStatusExt;
674 if let Some(sig) = status.signal() {
675 return 128 + sig;
676 }
677 }
678 1
679}
680
681pub fn aube_user_agent() -> String {
691 format!(
692 "{} {} {}",
693 aube_util::embedder().user_agent,
694 node_platform(),
695 node_arch(),
696 )
697}
698
699fn node_platform() -> &'static str {
700 match std::env::consts::OS {
701 "macos" => "darwin",
702 "windows" => "win32",
703 other => other,
704 }
705}
706
707fn node_arch() -> &'static str {
708 match std::env::consts::ARCH {
715 "x86_64" => "x64",
716 "aarch64" => "arm64",
717 "x86" => "ia32",
718 "powerpc" => "ppc",
719 "powerpc64" => "ppc64",
720 "loongarch64" => "loong64",
721 other => other,
722 }
723}
724
725fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
726 cmd.env_remove("AUBE_AUTH_TOKEN");
733 cmd.env("npm_config_user_agent", aube_user_agent());
738 let aube_exe = std::env::current_exe().ok();
744 if let Some(exe) = aube_exe.as_deref() {
745 cmd.env("npm_execpath", exe);
746 }
747 let node_execpath = settings
754 .node_execpath
755 .as_deref()
756 .or(settings.node_program.as_deref());
757 if let Some(execpath) = node_execpath {
758 cmd.env("npm_node_execpath", execpath);
759 }
760 if let Some(node) = settings.node_program.as_deref().or(node_execpath) {
761 cmd.env("NODE", node);
762 }
763 if let Some(command) = settings.command.as_deref() {
765 cmd.env("npm_command", command);
766 }
767 if let Some(node_gyp_js) = settings.node_gyp_js.as_deref() {
778 cmd.env("npm_config_node_gyp", node_gyp_js);
779 if let Some(exe) = aube_exe.as_deref() {
780 cmd.env("AUBE_NODE_GYP_EXE", exe);
781 }
782 }
783 if let Some(node_options) = settings.node_options.as_deref() {
784 cmd.env("NODE_OPTIONS", node_options);
785 }
786 if let Some(unsafe_perm) = settings.unsafe_perm {
787 cmd.env(
788 "npm_config_unsafe_perm",
789 if unsafe_perm { "true" } else { "false" },
790 );
791 }
792 if settings.shell_emulator {
793 cmd.env("npm_config_shell_emulator", "true");
794 }
795 if settings.http_proxy.is_some() || settings.https_proxy.is_some() {
810 if let Some(https) = settings.https_proxy.as_deref() {
811 cmd.env("HTTPS_PROXY", https);
812 }
813 if let Some(http) = settings.http_proxy.as_deref() {
814 cmd.env("HTTP_PROXY", http);
815 }
816 if let Some(no_proxy) = settings.no_proxy.as_deref() {
817 cmd.env("NO_PROXY", no_proxy);
818 }
819 cmd.env("NODE_USE_ENV_PROXY", "1");
820 }
821 for (key, value) in &settings.extra_env {
827 cmd.env(key, value);
828 }
829}
830
831pub fn apply_npm_manifest_env(
850 cmd: &mut tokio::process::Command,
851 manifest: &PackageJson,
852 script_dir: &Path,
853 lifecycle_script: &str,
854) {
855 for (key, _) in std::env::vars_os() {
856 if key.to_str().is_some_and(|k| k.starts_with("npm_package_")) {
857 cmd.env_remove(&key);
858 }
859 }
860 cmd.env("npm_lifecycle_script", lifecycle_script);
861 cmd.env("npm_package_json", script_dir.join("package.json"));
862 for (key, value) in manifest.npm_package_env() {
863 cmd.env(key, value);
864 }
865}
866
867fn safe_jail_env_key(key: &str) -> bool {
868 const EXACT: &[&str] = &[
869 "PATH",
870 "HOME",
871 "TERM",
872 "LANG",
873 "LC_ALL",
874 "INIT_CWD",
875 "npm_lifecycle_event",
876 "npm_package_name",
877 "npm_package_version",
878 ];
879 if EXACT.contains(&key) {
880 return true;
881 }
882 let lower = key.to_ascii_lowercase();
883 if lower.contains("token")
884 || lower.contains("auth")
885 || lower.contains("password")
886 || lower.contains("credential")
887 || lower.contains("secret")
888 {
889 return false;
890 }
891 key.starts_with("npm_config_")
892}
893
894fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
895 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
896 && !matches!(
897 key,
898 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
899 )
900}
901
902fn jail_home(package_dir: &Path) -> PathBuf {
903 let mut hasher = DefaultHasher::new();
904 package_dir.hash(&mut hasher);
905 let hash = hasher.finish();
906 let name = package_dir
907 .file_name()
908 .and_then(|s| s.to_str())
909 .unwrap_or("package")
910 .chars()
911 .map(|c| {
912 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
913 c
914 } else {
915 '_'
916 }
917 })
918 .collect::<String>();
919 std::env::temp_dir()
920 .join("aube-jail")
921 .join(std::process::id().to_string())
922 .join(format!("{name}-{hash:016x}"))
923}
924
925fn apply_jail_env(
926 cmd: &mut tokio::process::Command,
927 path_env: &std::ffi::OsStr,
928 home: &Path,
929 project_root: &Path,
930 manifest: &PackageJson,
931 script_name: &str,
932 extra_env: &[String],
933) {
934 cmd.env_clear();
935 cmd.env("PATH", path_env)
936 .env("HOME", home)
937 .env("TMPDIR", home)
938 .env("TMP", home)
939 .env("TEMP", home)
940 .env("npm_lifecycle_event", script_name);
941 if std::env::var_os("INIT_CWD").is_none() {
942 cmd.env("INIT_CWD", project_root);
943 }
944 if let Some(ref name) = manifest.name {
945 cmd.env("npm_package_name", name);
946 }
947 if let Some(ref version) = manifest.version {
948 cmd.env("npm_package_version", version);
949 }
950 for (key, val) in std::env::vars_os() {
951 let Some(key_str) = key.to_str() else {
952 continue;
953 };
954 if inherit_jail_env_key(key_str, extra_env) {
955 cmd.env(key, val);
956 }
957 }
958}
959
960#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum LifecycleHook {
965 PreInstall,
966 Install,
967 PostInstall,
968 Prepare,
969}
970
971impl LifecycleHook {
972 pub fn script_name(self) -> &'static str {
973 match self {
974 Self::PreInstall => "preinstall",
975 Self::Install => "install",
976 Self::PostInstall => "postinstall",
977 Self::Prepare => "prepare",
978 }
979 }
980}
981
982pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
986 LifecycleHook::PreInstall,
987 LifecycleHook::Install,
988 LifecycleHook::PostInstall,
989];
990
991#[cfg(unix)]
999static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
1000
1001#[cfg(unix)]
1006pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
1007 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
1008}
1009
1010#[cfg(not(unix))]
1015pub fn set_saved_stderr_fd(_fd: i32) {}
1016
1017#[cfg(unix)]
1022pub fn child_stderr() -> std::process::Stdio {
1023 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1024 if fd < 0 {
1025 return std::process::Stdio::inherit();
1026 }
1027 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1032 match borrowed.try_clone_to_owned() {
1033 Ok(owned) => std::process::Stdio::from(owned),
1034 Err(_) => std::process::Stdio::inherit(),
1035 }
1036}
1037
1038#[cfg(not(unix))]
1039pub fn child_stderr() -> std::process::Stdio {
1040 std::process::Stdio::inherit()
1041}
1042
1043#[cfg(unix)]
1059pub fn write_line_to_real_stderr(line: &str) {
1060 use std::io::Write;
1061 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1062 let fd = if saved >= 0 { saved } else { 2 };
1063 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1070 let Ok(owned) = borrowed.try_clone_to_owned() else {
1071 return;
1072 };
1073 let mut file = std::fs::File::from(owned);
1074 let mut buf = String::with_capacity(line.len() + 1);
1075 buf.push_str(line);
1076 buf.push('\n');
1077 let _ = file.write_all(buf.as_bytes());
1078}
1079
1080#[cfg(not(unix))]
1081pub fn write_line_to_real_stderr(line: &str) {
1082 eprintln!("{line}");
1083}
1084
1085async fn run_command_killing_descendants(
1121 mut cmd: tokio::process::Command,
1122 script_name: &str,
1123) -> Result<std::process::ExitStatus, Error> {
1124 let output_reporter = script_settings_state().output_reporter;
1125 if output_reporter.is_some() {
1126 cmd.stdout(std::process::Stdio::piped())
1127 .stderr(std::process::Stdio::piped());
1128 }
1129 let mut child = cmd
1130 .spawn()
1131 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1132 #[cfg(windows)]
1133 let _job = match windows_job::JobObject::new() {
1134 Ok(job) => {
1135 if let Some(handle) = child.raw_handle()
1139 && let Err(err) = job.assign(handle)
1140 {
1141 tracing::warn!(
1148 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1149 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
1150 grandchildren may be orphaned if the script is aborted"
1151 );
1152 }
1153 Some(job)
1154 }
1155 Err(err) => {
1156 tracing::warn!(
1157 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1158 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
1159 running without orphan-reaping — grandchildren may leak if aborted"
1160 );
1161 None
1162 }
1163 };
1164 let Some(reporter) = output_reporter else {
1165 return child
1166 .wait()
1167 .await
1168 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()));
1169 };
1170 let stdout = child.stdout.take().ok_or_else(|| {
1171 Error::Spawn(
1172 script_name.to_string(),
1173 "failed to capture lifecycle stdout".to_string(),
1174 )
1175 })?;
1176 let stderr = child.stderr.take().ok_or_else(|| {
1177 Error::Spawn(
1178 script_name.to_string(),
1179 "failed to capture lifecycle stderr".to_string(),
1180 )
1181 })?;
1182 let (status, stdout_result, stderr_result) = tokio::join!(
1183 child.wait(),
1184 report_script_output(stdout, ScriptOutputStream::Stdout, reporter.clone()),
1185 report_script_output(stderr, ScriptOutputStream::Stderr, reporter),
1186 );
1187 stdout_result.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1188 stderr_result.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1189 status.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
1190}
1191
1192async fn report_script_output<R: tokio::io::AsyncRead + Unpin>(
1193 reader: R,
1194 stream: ScriptOutputStream,
1195 reporter: std::sync::Arc<dyn ScriptOutputReporter>,
1196) -> std::io::Result<()> {
1197 let mut reader = tokio::io::BufReader::new(reader);
1198 let mut buffer = Vec::new();
1199 let mut continued_record = false;
1200 loop {
1201 buffer.clear();
1202 let mut limited = (&mut reader).take(MAX_SCRIPT_OUTPUT_RECORD_BYTES as u64);
1203 if limited.read_until(b'\n', &mut buffer).await? == 0 {
1204 return Ok(());
1205 }
1206 let record_terminated = buffer.last() == Some(&b'\n');
1207 if record_terminated {
1208 buffer.pop();
1209 if buffer.last() == Some(&b'\r') {
1210 buffer.pop();
1211 }
1212 }
1213 if !(continued_record && record_terminated && buffer.is_empty()) {
1214 reporter.report(stream, String::from_utf8_lossy(&buffer).into_owned());
1215 }
1216 continued_record = !record_terminated;
1217 }
1218}
1219
1220#[cfg(test)]
1221mod script_output_tests {
1222 use super::*;
1223 use tokio::io::AsyncWriteExt;
1224
1225 #[derive(Default)]
1226 struct RecordingReporter(std::sync::Mutex<Vec<String>>);
1227
1228 impl ScriptOutputReporter for RecordingReporter {
1229 fn report(&self, _stream: ScriptOutputStream, line: String) {
1230 self.0.lock().unwrap().push(line);
1231 }
1232 }
1233
1234 #[tokio::test]
1235 async fn unterminated_output_is_reported_in_bounded_chunks() {
1236 let reporter = std::sync::Arc::new(RecordingReporter::default());
1237 let (mut writer, reader) = tokio::io::duplex(1024);
1238 let mut output = vec![b'x'; MAX_SCRIPT_OUTPUT_RECORD_BYTES * 2 + 17];
1239 output.extend_from_slice(b"\nnext\n");
1240 let write = tokio::spawn(async move {
1241 writer.write_all(&output).await.unwrap();
1242 });
1243
1244 report_script_output(reader, ScriptOutputStream::Stdout, reporter.clone())
1245 .await
1246 .unwrap();
1247 write.await.unwrap();
1248
1249 let messages = reporter.0.lock().unwrap();
1250 assert_eq!(
1251 messages.iter().map(String::len).collect::<Vec<_>>(),
1252 [
1253 MAX_SCRIPT_OUTPUT_RECORD_BYTES,
1254 MAX_SCRIPT_OUTPUT_RECORD_BYTES,
1255 17,
1256 4,
1257 ]
1258 );
1259 assert_eq!(messages.last().map(String::as_str), Some("next"));
1260 }
1261}
1262
1263#[allow(clippy::too_many_arguments)]
1281pub async fn run_script(
1282 script_dir: &Path,
1283 project_root: &Path,
1284 modules_dir_name: &str,
1285 manifest: &PackageJson,
1286 script_name: &str,
1287 script_cmd: &str,
1288 extra_bin_dirs: &[&Path],
1289 jail: Option<&ScriptJail>,
1290) -> Result<(), Error> {
1291 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
1296 .with_meta_fn(|| {
1297 let pkg = manifest.name.as_deref().unwrap_or("(root)");
1298 format!(
1299 r#"{{"pkg":{},"script":{}}}"#,
1300 aube_util::diag::jstr(pkg),
1301 aube_util::diag::jstr(script_name)
1302 )
1303 });
1304 let project_bin = project_root.join(modules_dir_name).join(".bin");
1312 let state = script_settings_state();
1313 let settings = &state.settings;
1314 let path = std::env::var_os("PATH").unwrap_or_default();
1315 let mut project_bins: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
1316 for dir in extra_bin_dirs {
1317 project_bins.push(dir.to_path_buf());
1318 }
1319 project_bins.push(project_bin);
1320 let mut entries = order_path_entries(
1321 project_bins,
1322 settings.node_bin_dir.as_deref(),
1323 state.node_bin_dir_precedes_project_bins,
1324 );
1325 entries.extend(std::env::split_paths(&path));
1326 let new_path = std::env::join_paths(entries).unwrap_or(path);
1327 let jail_home = jail.map(|j| jail_home(&j.package_dir));
1328 if let Some(home) = &jail_home {
1329 std::fs::create_dir_all(home)
1330 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1331 }
1332 let mut cmd = match (jail, jail_home.as_deref()) {
1333 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, settings, jail, home),
1334 _ => spawn_shell_with_settings(script_cmd, settings),
1335 };
1336 cmd.current_dir(script_dir)
1337 .stderr(child_stderr())
1338 .env("PATH", &new_path)
1339 .env("npm_lifecycle_event", script_name);
1340
1341 if std::env::var_os("INIT_CWD").is_none() {
1348 cmd.env("INIT_CWD", project_root);
1349 }
1350
1351 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
1352 apply_jail_env(
1353 &mut cmd,
1354 &new_path,
1355 home,
1356 project_root,
1357 manifest,
1358 script_name,
1359 &jail.env,
1360 );
1361 apply_script_settings_env(&mut cmd, settings);
1362 }
1363
1364 apply_npm_manifest_env(&mut cmd, manifest, script_dir, script_cmd);
1368
1369 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
1370 let status = run_command_killing_descendants(cmd, script_name).await?;
1371
1372 if !status.success() {
1373 return Err(Error::NonZeroExit {
1374 script: script_name.to_string(),
1375 code: status.code(),
1376 });
1377 }
1378
1379 Ok(())
1380}
1381
1382pub async fn run_root_hook(
1388 project_dir: &Path,
1389 modules_dir_name: &str,
1390 manifest: &PackageJson,
1391 hook: LifecycleHook,
1392) -> Result<bool, Error> {
1393 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
1394}
1395
1396pub async fn run_root_script_by_name(
1403 project_dir: &Path,
1404 modules_dir_name: &str,
1405 manifest: &PackageJson,
1406 name: &str,
1407) -> Result<bool, Error> {
1408 let Some(script_cmd) = manifest.scripts.get(name) else {
1409 return Ok(false);
1410 };
1411 run_script(
1412 project_dir,
1413 project_dir,
1414 modules_dir_name,
1415 manifest,
1416 name,
1417 script_cmd,
1418 &[],
1419 None,
1420 )
1421 .await?;
1422 Ok(true)
1423}
1424
1425pub fn implicit_install_script(
1438 manifest: &PackageJson,
1439 has_binding_gyp: bool,
1440) -> Option<&'static str> {
1441 if !has_binding_gyp {
1442 return None;
1443 }
1444 if manifest
1445 .scripts
1446 .contains_key(LifecycleHook::Install.script_name())
1447 || manifest
1448 .scripts
1449 .contains_key(LifecycleHook::PreInstall.script_name())
1450 {
1451 return None;
1452 }
1453 Some("node-gyp rebuild")
1454}
1455
1456pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
1460 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
1461}
1462
1463pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
1468 if DEP_LIFECYCLE_HOOKS
1469 .iter()
1470 .any(|h| manifest.scripts.contains_key(h.script_name()))
1471 {
1472 return true;
1473 }
1474 default_install_script(package_dir, manifest).is_some()
1475}
1476
1477#[allow(clippy::too_many_arguments)]
1507pub async fn run_dep_hook(
1508 package_dir: &Path,
1509 dep_modules_dir: &Path,
1510 project_root: &Path,
1511 modules_dir_name: &str,
1512 manifest: &PackageJson,
1513 hook: LifecycleHook,
1514 tool_bin_dirs: &[&Path],
1515 jail: Option<&ScriptJail>,
1516) -> Result<bool, Error> {
1517 let name = hook.script_name();
1518 let script_cmd: &str = match manifest.scripts.get(name) {
1519 Some(s) => s.as_str(),
1520 None => match hook {
1521 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1522 Some(s) => s,
1523 None => return Ok(false),
1524 },
1525 _ => return Ok(false),
1526 },
1527 };
1528 let dep_bin_dir = dep_modules_dir.join(".bin");
1529 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1530 bin_dirs.push(&dep_bin_dir);
1531 bin_dirs.extend(tool_bin_dirs.iter().copied());
1532 run_script(
1533 package_dir,
1534 project_root,
1535 modules_dir_name,
1536 manifest,
1537 name,
1538 script_cmd,
1539 &bin_dirs,
1540 jail,
1541 )
1542 .await?;
1543 Ok(true)
1544}
1545
1546#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1547pub enum Error {
1548 #[error("failed to spawn script {0}: {1}")]
1549 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1550 Spawn(String, String),
1551 #[error("script `{script}` exited with code {code:?}")]
1552 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1553 NonZeroExit { script: String, code: Option<i32> },
1554}
1555
1556#[cfg(test)]
1557mod user_agent_tests {
1558 use super::*;
1559
1560 #[test]
1561 fn user_agent_uses_node_style_platform_and_arch() {
1562 let ua = aube_user_agent();
1563 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1565 let parts: Vec<&str> = ua.split(' ').collect();
1566 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1567 let platform = parts[1];
1569 assert!(
1570 matches!(
1571 platform,
1572 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1573 ),
1574 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1575 );
1576 let arch = parts[2];
1580 assert!(
1581 matches!(
1582 arch,
1583 "x64"
1584 | "arm64"
1585 | "ia32"
1586 | "arm"
1587 | "ppc"
1588 | "ppc64"
1589 | "loong64"
1590 | "mips"
1591 | "riscv64"
1592 | "s390x"
1593 ),
1594 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1595 );
1596 }
1597}
1598
1599#[cfg(test)]
1600mod jail_tests {
1601 use super::*;
1602
1603 #[test]
1604 fn jail_home_uses_full_package_path() {
1605 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1606 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1607
1608 assert_ne!(a, b);
1609 assert!(
1610 a.file_name()
1611 .unwrap()
1612 .to_string_lossy()
1613 .starts_with("native-")
1614 );
1615 assert!(
1616 b.file_name()
1617 .unwrap()
1618 .to_string_lossy()
1619 .starts_with("native-")
1620 );
1621 }
1622
1623 #[test]
1624 fn jail_home_cleanup_removes_temp_home() {
1625 let package_dir = std::env::temp_dir()
1626 .join("aube-jail-cleanup-test")
1627 .join(std::process::id().to_string())
1628 .join("node_modules")
1629 .join("native");
1630 let jail = ScriptJail::new(&package_dir);
1631 let home = jail_home(&package_dir);
1632 std::fs::create_dir_all(home.join(".cache")).unwrap();
1633 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1634
1635 {
1636 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1637 }
1638
1639 assert!(!home.exists());
1640 }
1641
1642 #[test]
1643 fn parent_env_cannot_override_explicit_jail_metadata() {
1644 for key in [
1645 "PATH",
1646 "HOME",
1647 "npm_lifecycle_event",
1648 "npm_package_name",
1649 "npm_package_version",
1650 ] {
1651 assert!(!inherit_jail_env_key(key, &[]));
1652 }
1653 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1654 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1655 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1656 assert!(inherit_jail_env_key(
1657 "SHARP_DIST_BASE_URL",
1658 &["SHARP_DIST_BASE_URL".to_string()]
1659 ));
1660 }
1661
1662 #[test]
1663 fn jail_env_preserves_script_settings_after_clear() {
1664 let mut cmd = tokio::process::Command::new("node");
1665 let manifest = PackageJson {
1666 name: Some("pkg".to_string()),
1667 version: Some("1.2.3".to_string()),
1668 ..Default::default()
1669 };
1670 let settings = ScriptSettings {
1671 node_options: Some("--conditions=aube".to_string()),
1672 unsafe_perm: Some(false),
1673 shell_emulator: true,
1674 ..Default::default()
1675 };
1676
1677 apply_jail_env(
1678 &mut cmd,
1679 std::ffi::OsStr::new("/bin"),
1680 Path::new("/tmp/aube-jail/home"),
1681 Path::new("/tmp/project"),
1682 &manifest,
1683 "postinstall",
1684 &[],
1685 );
1686 apply_script_settings_env(&mut cmd, &settings);
1687
1688 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1689 let env = |name: &str| {
1690 envs.iter()
1691 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1692 .and_then(|(_, val)| *val)
1693 .and_then(|val| val.to_str())
1694 };
1695
1696 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1697 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1698 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1699 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1700 assert_eq!(env("npm_package_name"), Some("pkg"));
1701 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1702 }
1703
1704 fn proxy_env(settings: ScriptSettings) -> impl Fn(&str) -> Option<String> {
1705 let mut cmd = tokio::process::Command::new("node");
1706 apply_script_settings_env(&mut cmd, &settings);
1707 let envs: Vec<_> = cmd
1708 .as_std()
1709 .get_envs()
1710 .map(|(k, v)| {
1711 (
1712 k.to_string_lossy().into_owned(),
1713 v.map(|v| v.to_string_lossy().into_owned()),
1714 )
1715 })
1716 .collect();
1717 move |name: &str| {
1718 envs.iter()
1719 .find(|(k, _)| k == name)
1720 .and_then(|(_, v)| v.clone())
1721 }
1722 }
1723
1724 #[test]
1725 fn proxy_vars_stamped_when_proxy_configured() {
1726 let env = proxy_env(ScriptSettings {
1727 https_proxy: Some("http://proxy.example:8080".to_string()),
1728 http_proxy: Some("http://proxy.example:8080".to_string()),
1729 no_proxy: Some("localhost,127.0.0.1".to_string()),
1730 ..Default::default()
1731 });
1732 assert_eq!(
1733 env("HTTPS_PROXY").as_deref(),
1734 Some("http://proxy.example:8080")
1735 );
1736 assert_eq!(
1737 env("HTTP_PROXY").as_deref(),
1738 Some("http://proxy.example:8080")
1739 );
1740 assert_eq!(env("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
1741 assert_eq!(env("NODE_USE_ENV_PROXY").as_deref(), Some("1"));
1744 }
1745
1746 #[test]
1747 fn proxy_block_skipped_when_no_proxy_configured() {
1748 let env = proxy_env(ScriptSettings::default());
1752 assert_eq!(env("HTTPS_PROXY"), None);
1753 assert_eq!(env("HTTP_PROXY"), None);
1754 assert_eq!(env("NO_PROXY"), None);
1755 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1756 }
1757
1758 #[test]
1759 fn no_proxy_alone_does_not_trigger_passthrough() {
1760 let env = proxy_env(ScriptSettings {
1763 no_proxy: Some("example.com".to_string()),
1764 ..Default::default()
1765 });
1766 assert_eq!(env("NO_PROXY"), None);
1767 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1768 }
1769
1770 #[test]
1771 fn wrapper_node_and_execpath_are_stamped_distinctly() {
1772 let env = proxy_env(ScriptSettings {
1776 node_program: Some(PathBuf::from("/shim/node")),
1777 node_execpath: Some(PathBuf::from("/real/node-24.4.1/bin/node")),
1778 extra_env: vec![("MYTOOL_WRAPPED".into(), "1".into())],
1779 ..Default::default()
1780 });
1781 assert_eq!(env("NODE").as_deref(), Some("/shim/node"));
1782 assert_eq!(
1783 env("npm_node_execpath").as_deref(),
1784 Some("/real/node-24.4.1/bin/node")
1785 );
1786 assert_eq!(env("MYTOOL_WRAPPED").as_deref(), Some("1"));
1787 }
1788
1789 #[test]
1790 fn node_execpath_falls_back_to_node_program() {
1791 let env = proxy_env(ScriptSettings {
1793 node_program: Some(PathBuf::from("/opt/node/bin/node")),
1794 ..Default::default()
1795 });
1796 assert_eq!(env("NODE").as_deref(), Some("/opt/node/bin/node"));
1797 assert_eq!(
1798 env("npm_node_execpath").as_deref(),
1799 Some("/opt/node/bin/node")
1800 );
1801 }
1802}
1803
1804#[cfg(all(test, windows))]
1805mod windows_quote_tests {
1806 use super::shell_quote_arg;
1807
1808 #[test]
1809 fn windows_path_backslash_not_doubled() {
1810 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1811 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1812 }
1813
1814 #[test]
1815 fn windows_trailing_backslash_doubled_before_close_quote() {
1816 let q = shell_quote_arg(r"C:\path\");
1817 assert_eq!(q, "\"C:\\path\\\\\"");
1818 }
1819
1820 #[test]
1821 fn windows_quote_in_arg_escapes_with_backslash() {
1822 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1823 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1824 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1825 }
1826}
1827
1828#[cfg(all(test, windows))]
1835mod windows_job_object_tests {
1836 use super::*;
1837 use std::time::{Duration, Instant};
1838 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1839 use windows_sys::Win32::System::Threading::{
1840 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1841 };
1842
1843 fn is_process_alive(pid: u32) -> bool {
1844 unsafe {
1848 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1849 if handle.is_null() {
1850 return false;
1851 }
1852 let mut code: u32 = 0;
1853 let ok = GetExitCodeProcess(handle, &mut code);
1854 CloseHandle(handle);
1855 ok != 0 && code == STILL_ACTIVE as u32
1856 }
1857 }
1858
1859 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1860 let start = Instant::now();
1861 while !check() {
1862 if start.elapsed() > timeout {
1863 return false;
1864 }
1865 tokio::time::sleep(Duration::from_millis(75)).await;
1866 }
1867 true
1868 }
1869
1870 #[tokio::test]
1871 async fn aborting_script_kills_grandchildren() {
1872 let nanos = std::time::SystemTime::now()
1876 .duration_since(std::time::UNIX_EPOCH)
1877 .unwrap_or_default()
1878 .as_nanos();
1879 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1880 let script = format!(
1889 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1890 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1891 & ping -n 10 127.0.0.1 >nul",
1892 pid_file.display()
1893 );
1894 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1895 let task = tokio::spawn(async move {
1896 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1897 });
1898
1899 let appeared = wait_until(
1900 || {
1901 std::fs::read_to_string(&pid_file)
1902 .ok()
1903 .and_then(|pid| pid.trim().parse::<u32>().ok())
1904 .is_some()
1905 },
1906 Duration::from_secs(20),
1907 )
1908 .await;
1909 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1910 let pid: u32 = std::fs::read_to_string(&pid_file)
1911 .expect("read pid file")
1912 .trim()
1913 .parse()
1914 .expect("pid file was parseable before reading");
1915 assert!(
1916 is_process_alive(pid),
1917 "grandchild pid {pid} not alive immediately after writing pid file"
1918 );
1919
1920 task.abort();
1925 let _ = task.await;
1926
1927 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1928 let _ = std::fs::remove_file(&pid_file);
1929 assert!(
1930 reaped,
1931 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1932 );
1933 }
1934}