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("AUBE_NODE_GYP_PROJECT_DIR", project_root)
946 .env("npm_lifecycle_event", script_name);
947 if std::env::var_os("INIT_CWD").is_none() {
948 cmd.env("INIT_CWD", project_root);
949 }
950 if let Some(ref name) = manifest.name {
951 cmd.env("npm_package_name", name);
952 }
953 if let Some(ref version) = manifest.version {
954 cmd.env("npm_package_version", version);
955 }
956 for (key, val) in std::env::vars_os() {
957 let Some(key_str) = key.to_str() else {
958 continue;
959 };
960 if inherit_jail_env_key(key_str, extra_env) {
961 cmd.env(key, val);
962 }
963 }
964}
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub enum LifecycleHook {
971 PreInstall,
972 Install,
973 PostInstall,
974 Prepare,
975}
976
977impl LifecycleHook {
978 pub fn script_name(self) -> &'static str {
979 match self {
980 Self::PreInstall => "preinstall",
981 Self::Install => "install",
982 Self::PostInstall => "postinstall",
983 Self::Prepare => "prepare",
984 }
985 }
986}
987
988pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
992 LifecycleHook::PreInstall,
993 LifecycleHook::Install,
994 LifecycleHook::PostInstall,
995];
996
997#[cfg(unix)]
1005static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
1006
1007#[cfg(unix)]
1012pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
1013 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
1014}
1015
1016#[cfg(not(unix))]
1021pub fn set_saved_stderr_fd(_fd: i32) {}
1022
1023#[cfg(unix)]
1028pub fn child_stderr() -> std::process::Stdio {
1029 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1030 if fd < 0 {
1031 return std::process::Stdio::inherit();
1032 }
1033 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1038 match borrowed.try_clone_to_owned() {
1039 Ok(owned) => std::process::Stdio::from(owned),
1040 Err(_) => std::process::Stdio::inherit(),
1041 }
1042}
1043
1044#[cfg(not(unix))]
1045pub fn child_stderr() -> std::process::Stdio {
1046 std::process::Stdio::inherit()
1047}
1048
1049#[cfg(unix)]
1065pub fn write_line_to_real_stderr(line: &str) {
1066 use std::io::Write;
1067 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
1068 let fd = if saved >= 0 { saved } else { 2 };
1069 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
1076 let Ok(owned) = borrowed.try_clone_to_owned() else {
1077 return;
1078 };
1079 let mut file = std::fs::File::from(owned);
1080 let mut buf = String::with_capacity(line.len() + 1);
1081 buf.push_str(line);
1082 buf.push('\n');
1083 let _ = file.write_all(buf.as_bytes());
1084}
1085
1086#[cfg(not(unix))]
1087pub fn write_line_to_real_stderr(line: &str) {
1088 eprintln!("{line}");
1089}
1090
1091async fn run_command_killing_descendants(
1127 mut cmd: tokio::process::Command,
1128 script_name: &str,
1129) -> Result<std::process::ExitStatus, Error> {
1130 let output_reporter = script_settings_state().output_reporter;
1131 if output_reporter.is_some() {
1132 cmd.stdout(std::process::Stdio::piped())
1133 .stderr(std::process::Stdio::piped());
1134 }
1135 let mut child = cmd
1136 .spawn()
1137 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1138 #[cfg(windows)]
1139 let _job = match windows_job::JobObject::new() {
1140 Ok(job) => {
1141 if let Some(handle) = child.raw_handle()
1145 && let Err(err) = job.assign(handle)
1146 {
1147 tracing::warn!(
1154 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1155 "windows: AssignProcessToJobObject failed for `{script_name}` shell ({err}); \
1156 grandchildren may be orphaned if the script is aborted"
1157 );
1158 }
1159 Some(job)
1160 }
1161 Err(err) => {
1162 tracing::warn!(
1163 code = aube_codes::warnings::WARN_AUBE_WINDOWS_JOB_OBJECT_UNAVAILABLE,
1164 "windows: CreateJobObjectW failed for `{script_name}` shell ({err}); \
1165 running without orphan-reaping — grandchildren may leak if aborted"
1166 );
1167 None
1168 }
1169 };
1170 let Some(reporter) = output_reporter else {
1171 return child
1172 .wait()
1173 .await
1174 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()));
1175 };
1176 let stdout = child.stdout.take().ok_or_else(|| {
1177 Error::Spawn(
1178 script_name.to_string(),
1179 "failed to capture lifecycle stdout".to_string(),
1180 )
1181 })?;
1182 let stderr = child.stderr.take().ok_or_else(|| {
1183 Error::Spawn(
1184 script_name.to_string(),
1185 "failed to capture lifecycle stderr".to_string(),
1186 )
1187 })?;
1188 let (status, stdout_result, stderr_result) = tokio::join!(
1189 child.wait(),
1190 report_script_output(stdout, ScriptOutputStream::Stdout, reporter.clone()),
1191 report_script_output(stderr, ScriptOutputStream::Stderr, reporter),
1192 );
1193 stdout_result.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1194 stderr_result.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1195 status.map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))
1196}
1197
1198async fn report_script_output<R: tokio::io::AsyncRead + Unpin>(
1199 reader: R,
1200 stream: ScriptOutputStream,
1201 reporter: std::sync::Arc<dyn ScriptOutputReporter>,
1202) -> std::io::Result<()> {
1203 let mut reader = tokio::io::BufReader::new(reader);
1204 let mut buffer = Vec::new();
1205 let mut continued_record = false;
1206 loop {
1207 buffer.clear();
1208 let mut limited = (&mut reader).take(MAX_SCRIPT_OUTPUT_RECORD_BYTES as u64);
1209 if limited.read_until(b'\n', &mut buffer).await? == 0 {
1210 return Ok(());
1211 }
1212 let record_terminated = buffer.last() == Some(&b'\n');
1213 if record_terminated {
1214 buffer.pop();
1215 if buffer.last() == Some(&b'\r') {
1216 buffer.pop();
1217 }
1218 }
1219 if !(continued_record && record_terminated && buffer.is_empty()) {
1220 reporter.report(stream, String::from_utf8_lossy(&buffer).into_owned());
1221 }
1222 continued_record = !record_terminated;
1223 }
1224}
1225
1226#[cfg(test)]
1227mod script_output_tests {
1228 use super::*;
1229 use tokio::io::AsyncWriteExt;
1230
1231 #[derive(Default)]
1232 struct RecordingReporter(std::sync::Mutex<Vec<String>>);
1233
1234 impl ScriptOutputReporter for RecordingReporter {
1235 fn report(&self, _stream: ScriptOutputStream, line: String) {
1236 self.0.lock().unwrap().push(line);
1237 }
1238 }
1239
1240 #[tokio::test]
1241 async fn unterminated_output_is_reported_in_bounded_chunks() {
1242 let reporter = std::sync::Arc::new(RecordingReporter::default());
1243 let (mut writer, reader) = tokio::io::duplex(1024);
1244 let mut output = vec![b'x'; MAX_SCRIPT_OUTPUT_RECORD_BYTES * 2 + 17];
1245 output.extend_from_slice(b"\nnext\n");
1246 let write = tokio::spawn(async move {
1247 writer.write_all(&output).await.unwrap();
1248 });
1249
1250 report_script_output(reader, ScriptOutputStream::Stdout, reporter.clone())
1251 .await
1252 .unwrap();
1253 write.await.unwrap();
1254
1255 let messages = reporter.0.lock().unwrap();
1256 assert_eq!(
1257 messages.iter().map(String::len).collect::<Vec<_>>(),
1258 [
1259 MAX_SCRIPT_OUTPUT_RECORD_BYTES,
1260 MAX_SCRIPT_OUTPUT_RECORD_BYTES,
1261 17,
1262 4,
1263 ]
1264 );
1265 assert_eq!(messages.last().map(String::as_str), Some("next"));
1266 }
1267}
1268
1269#[allow(clippy::too_many_arguments)]
1287pub async fn run_script(
1288 script_dir: &Path,
1289 project_root: &Path,
1290 modules_dir_name: &str,
1291 manifest: &PackageJson,
1292 script_name: &str,
1293 script_cmd: &str,
1294 extra_bin_dirs: &[&Path],
1295 jail: Option<&ScriptJail>,
1296) -> Result<(), Error> {
1297 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
1302 .with_meta_fn(|| {
1303 let pkg = manifest.name.as_deref().unwrap_or("(root)");
1304 format!(
1305 r#"{{"pkg":{},"script":{}}}"#,
1306 aube_util::diag::jstr(pkg),
1307 aube_util::diag::jstr(script_name)
1308 )
1309 });
1310 let project_bin = project_root.join(modules_dir_name).join(".bin");
1318 let state = script_settings_state();
1319 let settings = &state.settings;
1320 let path = std::env::var_os("PATH").unwrap_or_default();
1321 let mut project_bins: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
1322 for dir in extra_bin_dirs {
1323 project_bins.push(dir.to_path_buf());
1324 }
1325 project_bins.push(project_bin);
1326 let mut entries = order_path_entries(
1327 project_bins,
1328 settings.node_bin_dir.as_deref(),
1329 state.node_bin_dir_precedes_project_bins,
1330 );
1331 entries.extend(std::env::split_paths(&path));
1332 let new_path = std::env::join_paths(entries).unwrap_or(path);
1333 let jail_home = jail.map(|j| jail_home(&j.package_dir));
1334 if let Some(home) = &jail_home {
1335 std::fs::create_dir_all(home)
1336 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
1337 }
1338 let mut cmd = match (jail, jail_home.as_deref()) {
1339 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, settings, jail, home),
1340 _ => spawn_shell_with_settings(script_cmd, settings),
1341 };
1342 cmd.current_dir(script_dir)
1343 .stderr(child_stderr())
1344 .env("PATH", &new_path)
1345 .env("npm_lifecycle_event", script_name);
1346
1347 if std::env::var_os("INIT_CWD").is_none() {
1354 cmd.env("INIT_CWD", project_root);
1355 }
1356
1357 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
1358 apply_jail_env(
1359 &mut cmd,
1360 &new_path,
1361 home,
1362 project_root,
1363 manifest,
1364 script_name,
1365 &jail.env,
1366 );
1367 apply_script_settings_env(&mut cmd, settings);
1368 } else {
1369 cmd.env("AUBE_NODE_GYP_PROJECT_DIR", project_root);
1373 }
1374
1375 apply_npm_manifest_env(&mut cmd, manifest, script_dir, script_cmd);
1379
1380 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
1381 let status = run_command_killing_descendants(cmd, script_name).await?;
1382
1383 if !status.success() {
1384 return Err(Error::NonZeroExit {
1385 script: script_name.to_string(),
1386 code: status.code(),
1387 });
1388 }
1389
1390 Ok(())
1391}
1392
1393pub async fn run_root_hook(
1399 project_dir: &Path,
1400 modules_dir_name: &str,
1401 manifest: &PackageJson,
1402 hook: LifecycleHook,
1403) -> Result<bool, Error> {
1404 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
1405}
1406
1407pub async fn run_root_script_by_name(
1414 project_dir: &Path,
1415 modules_dir_name: &str,
1416 manifest: &PackageJson,
1417 name: &str,
1418) -> Result<bool, Error> {
1419 let Some(script_cmd) = manifest.scripts.get(name) else {
1420 return Ok(false);
1421 };
1422 run_script(
1423 project_dir,
1424 project_dir,
1425 modules_dir_name,
1426 manifest,
1427 name,
1428 script_cmd,
1429 &[],
1430 None,
1431 )
1432 .await?;
1433 Ok(true)
1434}
1435
1436pub fn implicit_install_script(
1449 manifest: &PackageJson,
1450 has_binding_gyp: bool,
1451) -> Option<&'static str> {
1452 if !has_binding_gyp {
1453 return None;
1454 }
1455 if manifest
1456 .scripts
1457 .contains_key(LifecycleHook::Install.script_name())
1458 || manifest
1459 .scripts
1460 .contains_key(LifecycleHook::PreInstall.script_name())
1461 {
1462 return None;
1463 }
1464 Some("node-gyp rebuild")
1465}
1466
1467pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
1471 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
1472}
1473
1474pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
1479 if DEP_LIFECYCLE_HOOKS
1480 .iter()
1481 .any(|h| manifest.scripts.contains_key(h.script_name()))
1482 {
1483 return true;
1484 }
1485 default_install_script(package_dir, manifest).is_some()
1486}
1487
1488#[allow(clippy::too_many_arguments)]
1518pub async fn run_dep_hook(
1519 package_dir: &Path,
1520 dep_modules_dir: &Path,
1521 project_root: &Path,
1522 modules_dir_name: &str,
1523 manifest: &PackageJson,
1524 hook: LifecycleHook,
1525 tool_bin_dirs: &[&Path],
1526 jail: Option<&ScriptJail>,
1527) -> Result<bool, Error> {
1528 let name = hook.script_name();
1529 let script_cmd: &str = match manifest.scripts.get(name) {
1530 Some(s) => s.as_str(),
1531 None => match hook {
1532 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
1533 Some(s) => s,
1534 None => return Ok(false),
1535 },
1536 _ => return Ok(false),
1537 },
1538 };
1539 let dep_bin_dir = dep_modules_dir.join(".bin");
1540 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
1541 bin_dirs.push(&dep_bin_dir);
1542 bin_dirs.extend(tool_bin_dirs.iter().copied());
1543 run_script(
1544 package_dir,
1545 project_root,
1546 modules_dir_name,
1547 manifest,
1548 name,
1549 script_cmd,
1550 &bin_dirs,
1551 jail,
1552 )
1553 .await?;
1554 Ok(true)
1555}
1556
1557#[derive(Debug, thiserror::Error, miette::Diagnostic)]
1558pub enum Error {
1559 #[error("failed to spawn script {0}: {1}")]
1560 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
1561 Spawn(String, String),
1562 #[error("script `{script}` exited with code {code:?}")]
1563 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
1564 NonZeroExit { script: String, code: Option<i32> },
1565}
1566
1567#[cfg(test)]
1568mod user_agent_tests {
1569 use super::*;
1570
1571 #[test]
1572 fn user_agent_uses_node_style_platform_and_arch() {
1573 let ua = aube_user_agent();
1574 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1576 let parts: Vec<&str> = ua.split(' ').collect();
1577 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1578 let platform = parts[1];
1580 assert!(
1581 matches!(
1582 platform,
1583 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1584 ),
1585 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1586 );
1587 let arch = parts[2];
1591 assert!(
1592 matches!(
1593 arch,
1594 "x64"
1595 | "arm64"
1596 | "ia32"
1597 | "arm"
1598 | "ppc"
1599 | "ppc64"
1600 | "loong64"
1601 | "mips"
1602 | "riscv64"
1603 | "s390x"
1604 ),
1605 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1606 );
1607 }
1608}
1609
1610#[cfg(test)]
1611mod jail_tests {
1612 use super::*;
1613
1614 #[test]
1615 fn jail_home_uses_full_package_path() {
1616 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1617 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1618
1619 assert_ne!(a, b);
1620 assert!(
1621 a.file_name()
1622 .unwrap()
1623 .to_string_lossy()
1624 .starts_with("native-")
1625 );
1626 assert!(
1627 b.file_name()
1628 .unwrap()
1629 .to_string_lossy()
1630 .starts_with("native-")
1631 );
1632 }
1633
1634 #[test]
1635 fn jail_home_cleanup_removes_temp_home() {
1636 let package_dir = std::env::temp_dir()
1637 .join("aube-jail-cleanup-test")
1638 .join(std::process::id().to_string())
1639 .join("node_modules")
1640 .join("native");
1641 let jail = ScriptJail::new(&package_dir);
1642 let home = jail_home(&package_dir);
1643 std::fs::create_dir_all(home.join(".cache")).unwrap();
1644 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1645
1646 {
1647 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1648 }
1649
1650 assert!(!home.exists());
1651 }
1652
1653 #[test]
1654 fn parent_env_cannot_override_explicit_jail_metadata() {
1655 for key in [
1656 "PATH",
1657 "HOME",
1658 "npm_lifecycle_event",
1659 "npm_package_name",
1660 "npm_package_version",
1661 ] {
1662 assert!(!inherit_jail_env_key(key, &[]));
1663 }
1664 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1665 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1666 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1667 assert!(inherit_jail_env_key(
1668 "SHARP_DIST_BASE_URL",
1669 &["SHARP_DIST_BASE_URL".to_string()]
1670 ));
1671 }
1672
1673 #[test]
1674 fn jail_env_preserves_script_settings_after_clear() {
1675 let mut cmd = tokio::process::Command::new("node");
1676 let manifest = PackageJson {
1677 name: Some("pkg".to_string()),
1678 version: Some("1.2.3".to_string()),
1679 ..Default::default()
1680 };
1681 let settings = ScriptSettings {
1682 node_options: Some("--conditions=aube".to_string()),
1683 unsafe_perm: Some(false),
1684 shell_emulator: true,
1685 ..Default::default()
1686 };
1687
1688 apply_jail_env(
1689 &mut cmd,
1690 std::ffi::OsStr::new("/bin"),
1691 Path::new("/tmp/aube-jail/home"),
1692 Path::new("/tmp/project"),
1693 &manifest,
1694 "postinstall",
1695 &[],
1696 );
1697 apply_script_settings_env(&mut cmd, &settings);
1698
1699 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1700 let env = |name: &str| {
1701 envs.iter()
1702 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1703 .and_then(|(_, val)| *val)
1704 .and_then(|val| val.to_str())
1705 };
1706
1707 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1708 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1709 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1710 assert_eq!(env("AUBE_NODE_GYP_PROJECT_DIR"), Some("/tmp/project"));
1711 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1712 assert_eq!(env("npm_package_name"), Some("pkg"));
1713 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1714 }
1715
1716 #[test]
1717 fn embedder_env_overrides_jailed_node_gyp_project_default() {
1718 let mut cmd = tokio::process::Command::new("node");
1719 let settings = ScriptSettings {
1720 extra_env: vec![(
1721 "AUBE_NODE_GYP_PROJECT_DIR".into(),
1722 "/tmp/embedder-project".into(),
1723 )],
1724 ..Default::default()
1725 };
1726
1727 apply_jail_env(
1728 &mut cmd,
1729 std::ffi::OsStr::new("/bin"),
1730 Path::new("/tmp/aube-jail/home"),
1731 Path::new("/tmp/project"),
1732 &PackageJson::default(),
1733 "postinstall",
1734 &[],
1735 );
1736 apply_script_settings_env(&mut cmd, &settings);
1737
1738 let project_dir = cmd
1739 .as_std()
1740 .get_envs()
1741 .find(|(key, _)| *key == std::ffi::OsStr::new("AUBE_NODE_GYP_PROJECT_DIR"))
1742 .and_then(|(_, value)| value)
1743 .and_then(|value| value.to_str());
1744 assert_eq!(project_dir, Some("/tmp/embedder-project"));
1745 }
1746
1747 fn proxy_env(settings: ScriptSettings) -> impl Fn(&str) -> Option<String> {
1748 let mut cmd = tokio::process::Command::new("node");
1749 apply_script_settings_env(&mut cmd, &settings);
1750 let envs: Vec<_> = cmd
1751 .as_std()
1752 .get_envs()
1753 .map(|(k, v)| {
1754 (
1755 k.to_string_lossy().into_owned(),
1756 v.map(|v| v.to_string_lossy().into_owned()),
1757 )
1758 })
1759 .collect();
1760 move |name: &str| {
1761 envs.iter()
1762 .find(|(k, _)| k == name)
1763 .and_then(|(_, v)| v.clone())
1764 }
1765 }
1766
1767 #[test]
1768 fn proxy_vars_stamped_when_proxy_configured() {
1769 let env = proxy_env(ScriptSettings {
1770 https_proxy: Some("http://proxy.example:8080".to_string()),
1771 http_proxy: Some("http://proxy.example:8080".to_string()),
1772 no_proxy: Some("localhost,127.0.0.1".to_string()),
1773 ..Default::default()
1774 });
1775 assert_eq!(
1776 env("HTTPS_PROXY").as_deref(),
1777 Some("http://proxy.example:8080")
1778 );
1779 assert_eq!(
1780 env("HTTP_PROXY").as_deref(),
1781 Some("http://proxy.example:8080")
1782 );
1783 assert_eq!(env("NO_PROXY").as_deref(), Some("localhost,127.0.0.1"));
1784 assert_eq!(env("NODE_USE_ENV_PROXY").as_deref(), Some("1"));
1787 }
1788
1789 #[test]
1790 fn proxy_block_skipped_when_no_proxy_configured() {
1791 let env = proxy_env(ScriptSettings::default());
1795 assert_eq!(env("HTTPS_PROXY"), None);
1796 assert_eq!(env("HTTP_PROXY"), None);
1797 assert_eq!(env("NO_PROXY"), None);
1798 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1799 }
1800
1801 #[test]
1802 fn no_proxy_alone_does_not_trigger_passthrough() {
1803 let env = proxy_env(ScriptSettings {
1806 no_proxy: Some("example.com".to_string()),
1807 ..Default::default()
1808 });
1809 assert_eq!(env("NO_PROXY"), None);
1810 assert_eq!(env("NODE_USE_ENV_PROXY"), None);
1811 }
1812
1813 #[test]
1814 fn wrapper_node_and_execpath_are_stamped_distinctly() {
1815 let env = proxy_env(ScriptSettings {
1819 node_program: Some(PathBuf::from("/shim/node")),
1820 node_execpath: Some(PathBuf::from("/real/node-24.4.1/bin/node")),
1821 extra_env: vec![("MYTOOL_WRAPPED".into(), "1".into())],
1822 ..Default::default()
1823 });
1824 assert_eq!(env("NODE").as_deref(), Some("/shim/node"));
1825 assert_eq!(
1826 env("npm_node_execpath").as_deref(),
1827 Some("/real/node-24.4.1/bin/node")
1828 );
1829 assert_eq!(env("MYTOOL_WRAPPED").as_deref(), Some("1"));
1830 }
1831
1832 #[test]
1833 fn node_execpath_falls_back_to_node_program() {
1834 let env = proxy_env(ScriptSettings {
1836 node_program: Some(PathBuf::from("/opt/node/bin/node")),
1837 ..Default::default()
1838 });
1839 assert_eq!(env("NODE").as_deref(), Some("/opt/node/bin/node"));
1840 assert_eq!(
1841 env("npm_node_execpath").as_deref(),
1842 Some("/opt/node/bin/node")
1843 );
1844 }
1845}
1846
1847#[cfg(all(test, windows))]
1848mod windows_quote_tests {
1849 use super::shell_quote_arg;
1850
1851 #[test]
1852 fn windows_path_backslash_not_doubled() {
1853 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1854 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1855 }
1856
1857 #[test]
1858 fn windows_trailing_backslash_doubled_before_close_quote() {
1859 let q = shell_quote_arg(r"C:\path\");
1860 assert_eq!(q, "\"C:\\path\\\\\"");
1861 }
1862
1863 #[test]
1864 fn windows_quote_in_arg_escapes_with_backslash() {
1865 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1866 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1867 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1868 }
1869}
1870
1871#[cfg(all(test, windows))]
1878mod windows_job_object_tests {
1879 use super::*;
1880 use std::time::{Duration, Instant};
1881 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
1882 use windows_sys::Win32::System::Threading::{
1883 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
1884 };
1885
1886 fn is_process_alive(pid: u32) -> bool {
1887 unsafe {
1891 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1892 if handle.is_null() {
1893 return false;
1894 }
1895 let mut code: u32 = 0;
1896 let ok = GetExitCodeProcess(handle, &mut code);
1897 CloseHandle(handle);
1898 ok != 0 && code == STILL_ACTIVE as u32
1899 }
1900 }
1901
1902 async fn wait_until<F: Fn() -> bool>(check: F, timeout: Duration) -> bool {
1903 let start = Instant::now();
1904 while !check() {
1905 if start.elapsed() > timeout {
1906 return false;
1907 }
1908 tokio::time::sleep(Duration::from_millis(75)).await;
1909 }
1910 true
1911 }
1912
1913 #[tokio::test]
1914 async fn aborting_script_kills_grandchildren() {
1915 let nanos = std::time::SystemTime::now()
1919 .duration_since(std::time::UNIX_EPOCH)
1920 .unwrap_or_default()
1921 .as_nanos();
1922 let pid_file = std::env::temp_dir().join(format!("aube-test-grandchild-{nanos}.pid"));
1923 let script = format!(
1932 "start /b powershell -NoProfile -WindowStyle Hidden -Command \
1933 \"$pid | Out-File -Encoding ascii -FilePath '{}'; Start-Sleep 60\" \
1934 & ping -n 10 127.0.0.1 >nul",
1935 pid_file.display()
1936 );
1937 let cmd = spawn_shell_with_settings(&script, &ScriptSettings::default());
1938 let task = tokio::spawn(async move {
1939 let _ = run_command_killing_descendants(cmd, "test-grandchild").await;
1940 });
1941
1942 let appeared = wait_until(
1943 || {
1944 std::fs::read_to_string(&pid_file)
1945 .ok()
1946 .and_then(|pid| pid.trim().parse::<u32>().ok())
1947 .is_some()
1948 },
1949 Duration::from_secs(20),
1950 )
1951 .await;
1952 assert!(appeared, "grandchild never wrote pid file at {pid_file:?}");
1953 let pid: u32 = std::fs::read_to_string(&pid_file)
1954 .expect("read pid file")
1955 .trim()
1956 .parse()
1957 .expect("pid file was parseable before reading");
1958 assert!(
1959 is_process_alive(pid),
1960 "grandchild pid {pid} not alive immediately after writing pid file"
1961 );
1962
1963 task.abort();
1968 let _ = task.await;
1969
1970 let reaped = wait_until(|| !is_process_alive(pid), Duration::from_secs(10)).await;
1971 let _ = std::fs::remove_file(&pid_file);
1972 assert!(
1973 reaped,
1974 "grandchild pid {pid} survived parent abort — job object did not kill the tree"
1975 );
1976 }
1977}