1pub mod policy;
16
17#[cfg(target_os = "linux")]
18mod linux_jail;
19
20pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError, pattern_matches};
21
22use aube_manifest::PackageJson;
23use std::collections::hash_map::DefaultHasher;
24use std::hash::{Hash, Hasher};
25use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Default)]
29pub struct ScriptSettings {
30 pub node_options: Option<String>,
31 pub script_shell: Option<PathBuf>,
32 pub unsafe_perm: Option<bool>,
33 pub shell_emulator: bool,
34}
35
36#[derive(Debug, Clone)]
38pub struct ScriptJail {
39 pub package_dir: PathBuf,
40 pub env: Vec<String>,
41 pub read_paths: Vec<PathBuf>,
42 pub write_paths: Vec<PathBuf>,
43 pub network: bool,
44}
45
46impl ScriptJail {
47 pub fn new(package_dir: impl Into<PathBuf>) -> Self {
48 Self {
49 package_dir: package_dir.into(),
50 env: Vec::new(),
51 read_paths: Vec::new(),
52 write_paths: Vec::new(),
53 network: false,
54 }
55 }
56
57 pub fn with_env(mut self, env: impl IntoIterator<Item = String>) -> Self {
58 self.env = env.into_iter().collect();
59 self
60 }
61
62 pub fn with_read_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
63 self.read_paths = paths.into_iter().collect();
64 self
65 }
66
67 pub fn with_write_paths(mut self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
68 self.write_paths = paths.into_iter().collect();
69 self
70 }
71
72 pub fn with_network(mut self, network: bool) -> Self {
73 self.network = network;
74 self
75 }
76}
77
78pub struct ScriptJailHomeCleanup {
79 path: PathBuf,
80}
81
82impl ScriptJailHomeCleanup {
83 pub fn new(jail: &ScriptJail) -> Self {
84 Self {
85 path: jail_home(&jail.package_dir),
86 }
87 }
88}
89
90impl Drop for ScriptJailHomeCleanup {
91 fn drop(&mut self) {
92 if self.path.exists()
93 && let Err(err) = std::fs::remove_dir_all(&self.path)
94 {
95 tracing::debug!("failed to clean jail HOME {}: {err}", self.path.display());
96 }
97 }
98}
99
100static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
101 std::sync::OnceLock::new();
102
103fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
104 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
105}
106
107pub fn set_script_settings(settings: ScriptSettings) {
111 match script_settings_lock().write() {
112 Ok(mut guard) => *guard = settings,
113 Err(poisoned) => *poisoned.into_inner() = settings,
114 }
115}
116
117fn script_settings() -> ScriptSettings {
118 match script_settings_lock().read() {
119 Ok(guard) => guard.clone(),
120 Err(poisoned) => poisoned.into_inner().clone(),
121 }
122}
123
124pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
127 let path = std::env::var_os("PATH").unwrap_or_default();
128 let mut entries = vec![bin_dir.to_path_buf()];
129 entries.extend(std::env::split_paths(&path));
130 std::env::join_paths(entries).unwrap_or(path)
131}
132
133pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
152 let settings = script_settings();
153 spawn_shell_with_settings(script_cmd, &settings)
154}
155
156fn spawn_shell_with_settings(
157 script_cmd: &str,
158 settings: &ScriptSettings,
159) -> tokio::process::Command {
160 #[cfg(unix)]
161 {
162 let mut cmd = tokio::process::Command::new(
163 settings
164 .script_shell
165 .as_deref()
166 .unwrap_or_else(|| Path::new("sh")),
167 );
168 cmd.arg("-c").arg(script_cmd);
169 apply_script_settings_env(&mut cmd, settings);
170 cmd
171 }
172 #[cfg(windows)]
173 {
174 let mut cmd = tokio::process::Command::new(
175 settings
176 .script_shell
177 .as_deref()
178 .unwrap_or_else(|| Path::new("cmd.exe")),
179 );
180 if settings.script_shell.is_some() {
181 cmd.arg("-c").arg(script_cmd);
182 } else {
183 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
188 }
189 apply_script_settings_env(&mut cmd, settings);
190 cmd
191 }
192}
193
194#[cfg(target_os = "macos")]
195fn sbpl_escape(s: &str) -> String {
196 s.replace('\\', "\\\\").replace('"', "\\\"")
197}
198
199#[cfg(target_os = "macos")]
200fn push_write_rule(rules: &mut Vec<String>, path: &Path) {
201 let path = sbpl_escape(&path.to_string_lossy());
202 let rule = format!("(allow file-write* (subpath \"{path}\"))");
203 if !rules.iter().any(|existing| existing == &rule) {
204 rules.push(rule);
205 }
206}
207
208#[cfg(target_os = "macos")]
209fn jail_profile(jail: &ScriptJail, home: &Path) -> String {
210 let mut rules = vec![
211 "(version 1)".to_string(),
212 "(allow default)".to_string(),
213 "(allow network* (local unix))".to_string(),
214 "(deny file-write*)".to_string(),
215 ];
216 if !jail.network {
217 rules.insert(2, "(deny network*)".to_string());
218 }
219
220 for path in [
221 Path::new("/tmp"),
222 Path::new("/private/tmp"),
223 Path::new("/dev"),
224 ] {
225 push_write_rule(&mut rules, path);
226 }
227 for path in [&jail.package_dir, home] {
228 push_write_rule(&mut rules, path);
229 }
230 for path in &jail.write_paths {
231 push_write_rule(&mut rules, path);
232 }
233 for path in [&jail.package_dir, home] {
234 if let Ok(canonical) = path.canonicalize() {
235 push_write_rule(&mut rules, &canonical);
236 }
237 }
238 for path in &jail.write_paths {
239 if let Ok(canonical) = path.canonicalize() {
240 push_write_rule(&mut rules, &canonical);
241 }
242 }
243 rules.join("\n")
244}
245
246#[cfg(target_os = "macos")]
247fn spawn_jailed_shell(
248 script_cmd: &str,
249 settings: &ScriptSettings,
250 jail: &ScriptJail,
251 home: &Path,
252) -> tokio::process::Command {
253 let shell = settings
254 .script_shell
255 .as_deref()
256 .unwrap_or_else(|| Path::new("sh"));
257 let profile = jail_profile(jail, home);
258 let mut cmd = tokio::process::Command::new("sandbox-exec");
259 cmd.arg("-p")
260 .arg(profile)
261 .arg("--")
262 .arg(shell)
263 .arg("-c")
264 .arg(script_cmd);
265 apply_script_settings_env(&mut cmd, settings);
266 cmd
267}
268
269#[cfg(target_os = "linux")]
270fn spawn_jailed_shell(
271 script_cmd: &str,
272 settings: &ScriptSettings,
273 jail: &ScriptJail,
274 home: &Path,
275) -> tokio::process::Command {
276 let mut cmd = spawn_shell_with_settings(script_cmd, settings);
277 let jail = jail.clone();
278 let home = home.to_path_buf();
279 unsafe {
280 cmd.pre_exec(move || {
281 linux_jail::apply_landlock(&jail, &home).map_err(std::io::Error::other)?;
282 if !jail.network {
283 linux_jail::apply_seccomp_net_filter().map_err(std::io::Error::other)?;
284 }
285 Ok(())
286 });
287 }
288 cmd
289}
290
291#[cfg(not(any(target_os = "linux", target_os = "macos")))]
292fn spawn_jailed_shell(
293 script_cmd: &str,
294 settings: &ScriptSettings,
295 _jail: &ScriptJail,
296 _home: &Path,
297) -> tokio::process::Command {
298 spawn_shell_with_settings(script_cmd, settings)
299}
300
301pub fn shell_quote_arg(arg: &str) -> String {
324 #[cfg(unix)]
325 {
326 let mut out = String::with_capacity(arg.len() + 2);
327 out.push('\'');
328 for ch in arg.chars() {
329 if ch == '\'' {
330 out.push_str("'\\''");
331 } else {
332 out.push(ch);
333 }
334 }
335 out.push('\'');
336 out
337 }
338 #[cfg(windows)]
339 {
340 let mut out = String::with_capacity(arg.len() + 2);
341 out.push('"');
342 let mut backslashes: usize = 0;
343 for ch in arg.chars() {
344 match ch {
345 '\\' => backslashes += 1,
346 '"' => {
347 for _ in 0..backslashes * 2 + 1 {
348 out.push('\\');
349 }
350 out.push('"');
351 backslashes = 0;
352 }
353 '%' => {
364 for _ in 0..backslashes {
365 out.push('\\');
366 }
367 backslashes = 0;
368 out.push_str("%%");
369 }
370 _ => {
371 for _ in 0..backslashes {
372 out.push('\\');
373 }
374 backslashes = 0;
375 out.push(ch);
376 }
377 }
378 }
379 for _ in 0..backslashes * 2 {
380 out.push('\\');
381 }
382 out.push('"');
383 out
384 }
385}
386
387pub fn exit_code_from_status(status: std::process::ExitStatus) -> i32 {
399 if let Some(code) = status.code() {
400 return code;
401 }
402 #[cfg(unix)]
403 {
404 use std::os::unix::process::ExitStatusExt;
405 if let Some(sig) = status.signal() {
406 return 128 + sig;
407 }
408 }
409 1
410}
411
412pub fn aube_user_agent() -> String {
422 format!(
423 "aube/{} {} {}",
424 env!("CARGO_PKG_VERSION"),
425 node_platform(),
426 node_arch(),
427 )
428}
429
430fn node_platform() -> &'static str {
431 match std::env::consts::OS {
432 "macos" => "darwin",
433 "windows" => "win32",
434 other => other,
435 }
436}
437
438fn node_arch() -> &'static str {
439 match std::env::consts::ARCH {
446 "x86_64" => "x64",
447 "aarch64" => "arm64",
448 "x86" => "ia32",
449 "powerpc" => "ppc",
450 "powerpc64" => "ppc64",
451 "loongarch64" => "loong64",
452 other => other,
453 }
454}
455
456fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
457 cmd.env_remove("AUBE_AUTH_TOKEN");
464 cmd.env("npm_config_user_agent", aube_user_agent());
469 if let Some(node_options) = settings.node_options.as_deref() {
470 cmd.env("NODE_OPTIONS", node_options);
471 }
472 if let Some(unsafe_perm) = settings.unsafe_perm {
473 cmd.env(
474 "npm_config_unsafe_perm",
475 if unsafe_perm { "true" } else { "false" },
476 );
477 }
478 if settings.shell_emulator {
479 cmd.env("npm_config_shell_emulator", "true");
480 }
481}
482
483fn safe_jail_env_key(key: &str) -> bool {
484 const EXACT: &[&str] = &[
485 "PATH",
486 "HOME",
487 "TERM",
488 "LANG",
489 "LC_ALL",
490 "INIT_CWD",
491 "npm_lifecycle_event",
492 "npm_package_name",
493 "npm_package_version",
494 ];
495 if EXACT.contains(&key) {
496 return true;
497 }
498 let lower = key.to_ascii_lowercase();
499 if lower.contains("token")
500 || lower.contains("auth")
501 || lower.contains("password")
502 || lower.contains("credential")
503 || lower.contains("secret")
504 {
505 return false;
506 }
507 key.starts_with("npm_config_")
508}
509
510fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
511 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
512 && !matches!(
513 key,
514 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
515 )
516}
517
518fn jail_home(package_dir: &Path) -> PathBuf {
519 let mut hasher = DefaultHasher::new();
520 package_dir.hash(&mut hasher);
521 let hash = hasher.finish();
522 let name = package_dir
523 .file_name()
524 .and_then(|s| s.to_str())
525 .unwrap_or("package")
526 .chars()
527 .map(|c| {
528 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
529 c
530 } else {
531 '_'
532 }
533 })
534 .collect::<String>();
535 std::env::temp_dir()
536 .join("aube-jail")
537 .join(std::process::id().to_string())
538 .join(format!("{name}-{hash:016x}"))
539}
540
541fn apply_jail_env(
542 cmd: &mut tokio::process::Command,
543 path_env: &std::ffi::OsStr,
544 home: &Path,
545 project_root: &Path,
546 manifest: &PackageJson,
547 script_name: &str,
548 extra_env: &[String],
549) {
550 cmd.env_clear();
551 cmd.env("PATH", path_env)
552 .env("HOME", home)
553 .env("TMPDIR", home)
554 .env("TMP", home)
555 .env("TEMP", home)
556 .env("npm_lifecycle_event", script_name);
557 if std::env::var_os("INIT_CWD").is_none() {
558 cmd.env("INIT_CWD", project_root);
559 }
560 if let Some(ref name) = manifest.name {
561 cmd.env("npm_package_name", name);
562 }
563 if let Some(ref version) = manifest.version {
564 cmd.env("npm_package_version", version);
565 }
566 for (key, val) in std::env::vars_os() {
567 let Some(key_str) = key.to_str() else {
568 continue;
569 };
570 if inherit_jail_env_key(key_str, extra_env) {
571 cmd.env(key, val);
572 }
573 }
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub enum LifecycleHook {
581 PreInstall,
582 Install,
583 PostInstall,
584 Prepare,
585}
586
587impl LifecycleHook {
588 pub fn script_name(self) -> &'static str {
589 match self {
590 Self::PreInstall => "preinstall",
591 Self::Install => "install",
592 Self::PostInstall => "postinstall",
593 Self::Prepare => "prepare",
594 }
595 }
596}
597
598pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
602 LifecycleHook::PreInstall,
603 LifecycleHook::Install,
604 LifecycleHook::PostInstall,
605];
606
607#[cfg(unix)]
615static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
616
617#[cfg(unix)]
622pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
623 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
624}
625
626#[cfg(not(unix))]
631pub fn set_saved_stderr_fd(_fd: i32) {}
632
633#[cfg(unix)]
638pub fn child_stderr() -> std::process::Stdio {
639 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
640 if fd < 0 {
641 return std::process::Stdio::inherit();
642 }
643 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
648 match borrowed.try_clone_to_owned() {
649 Ok(owned) => std::process::Stdio::from(owned),
650 Err(_) => std::process::Stdio::inherit(),
651 }
652}
653
654#[cfg(not(unix))]
655pub fn child_stderr() -> std::process::Stdio {
656 std::process::Stdio::inherit()
657}
658
659#[cfg(unix)]
675pub fn write_line_to_real_stderr(line: &str) {
676 use std::io::Write;
677 let saved = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
678 let fd = if saved >= 0 { saved } else { 2 };
679 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
686 let Ok(owned) = borrowed.try_clone_to_owned() else {
687 return;
688 };
689 let mut file = std::fs::File::from(owned);
690 let mut buf = String::with_capacity(line.len() + 1);
691 buf.push_str(line);
692 buf.push('\n');
693 let _ = file.write_all(buf.as_bytes());
694}
695
696#[cfg(not(unix))]
697pub fn write_line_to_real_stderr(line: &str) {
698 eprintln!("{line}");
699}
700
701#[allow(clippy::too_many_arguments)]
718pub async fn run_script(
719 script_dir: &Path,
720 project_root: &Path,
721 modules_dir_name: &str,
722 manifest: &PackageJson,
723 script_name: &str,
724 script_cmd: &str,
725 extra_bin_dirs: &[&Path],
726 jail: Option<&ScriptJail>,
727) -> Result<(), Error> {
728 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Script, "run_script")
733 .with_meta_fn(|| {
734 let pkg = manifest.name.as_deref().unwrap_or("(root)");
735 format!(
736 r#"{{"pkg":{},"script":{}}}"#,
737 aube_util::diag::jstr(pkg),
738 aube_util::diag::jstr(script_name)
739 )
740 });
741 let project_bin = project_root.join(modules_dir_name).join(".bin");
749 let path = std::env::var_os("PATH").unwrap_or_default();
750 let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
751 for dir in extra_bin_dirs {
752 entries.push(dir.to_path_buf());
753 }
754 entries.push(project_bin);
755 entries.extend(std::env::split_paths(&path));
756 let new_path = std::env::join_paths(entries).unwrap_or(path);
757
758 let settings = script_settings();
759 let jail_home = jail.map(|j| jail_home(&j.package_dir));
760 if let Some(home) = &jail_home {
761 std::fs::create_dir_all(home)
762 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
763 }
764 let mut cmd = match (jail, jail_home.as_deref()) {
765 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
766 _ => spawn_shell_with_settings(script_cmd, &settings),
767 };
768 cmd.current_dir(script_dir)
769 .stderr(child_stderr())
770 .env("PATH", &new_path)
771 .env("npm_lifecycle_event", script_name);
772
773 if std::env::var_os("INIT_CWD").is_none() {
780 cmd.env("INIT_CWD", project_root);
781 }
782
783 if let Some(ref name) = manifest.name {
784 cmd.env("npm_package_name", name);
785 }
786 if let Some(ref version) = manifest.version {
787 cmd.env("npm_package_version", version);
788 }
789 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
790 apply_jail_env(
791 &mut cmd,
792 &new_path,
793 home,
794 project_root,
795 manifest,
796 script_name,
797 &jail.env,
798 );
799 apply_script_settings_env(&mut cmd, &settings);
800 }
801
802 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
803 let status = cmd
804 .status()
805 .await
806 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
807
808 if !status.success() {
809 return Err(Error::NonZeroExit {
810 script: script_name.to_string(),
811 code: status.code(),
812 });
813 }
814
815 Ok(())
816}
817
818pub async fn run_root_hook(
824 project_dir: &Path,
825 modules_dir_name: &str,
826 manifest: &PackageJson,
827 hook: LifecycleHook,
828) -> Result<bool, Error> {
829 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
830}
831
832pub async fn run_root_script_by_name(
839 project_dir: &Path,
840 modules_dir_name: &str,
841 manifest: &PackageJson,
842 name: &str,
843) -> Result<bool, Error> {
844 let Some(script_cmd) = manifest.scripts.get(name) else {
845 return Ok(false);
846 };
847 run_script(
848 project_dir,
849 project_dir,
850 modules_dir_name,
851 manifest,
852 name,
853 script_cmd,
854 &[],
855 None,
856 )
857 .await?;
858 Ok(true)
859}
860
861pub fn implicit_install_script(
874 manifest: &PackageJson,
875 has_binding_gyp: bool,
876) -> Option<&'static str> {
877 if !has_binding_gyp {
878 return None;
879 }
880 if manifest
881 .scripts
882 .contains_key(LifecycleHook::Install.script_name())
883 || manifest
884 .scripts
885 .contains_key(LifecycleHook::PreInstall.script_name())
886 {
887 return None;
888 }
889 Some("node-gyp rebuild")
890}
891
892pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
896 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
897}
898
899pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
904 if DEP_LIFECYCLE_HOOKS
905 .iter()
906 .any(|h| manifest.scripts.contains_key(h.script_name()))
907 {
908 return true;
909 }
910 default_install_script(package_dir, manifest).is_some()
911}
912
913#[allow(clippy::too_many_arguments)]
943pub async fn run_dep_hook(
944 package_dir: &Path,
945 dep_modules_dir: &Path,
946 project_root: &Path,
947 modules_dir_name: &str,
948 manifest: &PackageJson,
949 hook: LifecycleHook,
950 tool_bin_dirs: &[&Path],
951 jail: Option<&ScriptJail>,
952) -> Result<bool, Error> {
953 let name = hook.script_name();
954 let script_cmd: &str = match manifest.scripts.get(name) {
955 Some(s) => s.as_str(),
956 None => match hook {
957 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
958 Some(s) => s,
959 None => return Ok(false),
960 },
961 _ => return Ok(false),
962 },
963 };
964 let dep_bin_dir = dep_modules_dir.join(".bin");
965 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
966 bin_dirs.push(&dep_bin_dir);
967 bin_dirs.extend(tool_bin_dirs.iter().copied());
968 run_script(
969 package_dir,
970 project_root,
971 modules_dir_name,
972 manifest,
973 name,
974 script_cmd,
975 &bin_dirs,
976 jail,
977 )
978 .await?;
979 Ok(true)
980}
981
982#[derive(Debug, thiserror::Error, miette::Diagnostic)]
983pub enum Error {
984 #[error("failed to spawn script {0}: {1}")]
985 #[diagnostic(code(ERR_AUBE_SCRIPT_SPAWN))]
986 Spawn(String, String),
987 #[error("script `{script}` exited with code {code:?}")]
988 #[diagnostic(code(ERR_AUBE_SCRIPT_NON_ZERO_EXIT))]
989 NonZeroExit { script: String, code: Option<i32> },
990}
991
992#[cfg(test)]
993mod user_agent_tests {
994 use super::*;
995
996 #[test]
997 fn user_agent_uses_node_style_platform_and_arch() {
998 let ua = aube_user_agent();
999 assert!(ua.starts_with("aube/"), "unexpected prefix: {ua}");
1001 let parts: Vec<&str> = ua.split(' ').collect();
1002 assert_eq!(parts.len(), 3, "expected 3 space-separated fields: {ua}");
1003 let platform = parts[1];
1005 assert!(
1006 matches!(
1007 platform,
1008 "darwin" | "linux" | "win32" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
1009 ),
1010 "platform `{platform}` should follow Node's `process.platform` vocabulary"
1011 );
1012 let arch = parts[2];
1016 assert!(
1017 matches!(
1018 arch,
1019 "x64"
1020 | "arm64"
1021 | "ia32"
1022 | "arm"
1023 | "ppc"
1024 | "ppc64"
1025 | "loong64"
1026 | "mips"
1027 | "riscv64"
1028 | "s390x"
1029 ),
1030 "arch `{arch}` should follow Node's `process.arch` vocabulary"
1031 );
1032 }
1033}
1034
1035#[cfg(test)]
1036mod jail_tests {
1037 use super::*;
1038
1039 #[test]
1040 fn jail_home_uses_full_package_path() {
1041 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
1042 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
1043
1044 assert_ne!(a, b);
1045 assert!(
1046 a.file_name()
1047 .unwrap()
1048 .to_string_lossy()
1049 .starts_with("native-")
1050 );
1051 assert!(
1052 b.file_name()
1053 .unwrap()
1054 .to_string_lossy()
1055 .starts_with("native-")
1056 );
1057 }
1058
1059 #[test]
1060 fn jail_home_cleanup_removes_temp_home() {
1061 let package_dir = std::env::temp_dir()
1062 .join("aube-jail-cleanup-test")
1063 .join(std::process::id().to_string())
1064 .join("node_modules")
1065 .join("native");
1066 let jail = ScriptJail::new(&package_dir);
1067 let home = jail_home(&package_dir);
1068 std::fs::create_dir_all(home.join(".cache")).unwrap();
1069 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
1070
1071 {
1072 let _cleanup = ScriptJailHomeCleanup::new(&jail);
1073 }
1074
1075 assert!(!home.exists());
1076 }
1077
1078 #[test]
1079 fn parent_env_cannot_override_explicit_jail_metadata() {
1080 for key in [
1081 "PATH",
1082 "HOME",
1083 "npm_lifecycle_event",
1084 "npm_package_name",
1085 "npm_package_version",
1086 ] {
1087 assert!(!inherit_jail_env_key(key, &[]));
1088 }
1089 assert!(inherit_jail_env_key("INIT_CWD", &[]));
1090 assert!(inherit_jail_env_key("npm_config_arch", &[]));
1091 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
1092 assert!(inherit_jail_env_key(
1093 "SHARP_DIST_BASE_URL",
1094 &["SHARP_DIST_BASE_URL".to_string()]
1095 ));
1096 }
1097
1098 #[test]
1099 fn jail_env_preserves_script_settings_after_clear() {
1100 let mut cmd = tokio::process::Command::new("node");
1101 let manifest = PackageJson {
1102 name: Some("pkg".to_string()),
1103 version: Some("1.2.3".to_string()),
1104 ..Default::default()
1105 };
1106 let settings = ScriptSettings {
1107 node_options: Some("--conditions=aube".to_string()),
1108 unsafe_perm: Some(false),
1109 shell_emulator: true,
1110 ..Default::default()
1111 };
1112
1113 apply_jail_env(
1114 &mut cmd,
1115 std::ffi::OsStr::new("/bin"),
1116 Path::new("/tmp/aube-jail/home"),
1117 Path::new("/tmp/project"),
1118 &manifest,
1119 "postinstall",
1120 &[],
1121 );
1122 apply_script_settings_env(&mut cmd, &settings);
1123
1124 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
1125 let env = |name: &str| {
1126 envs.iter()
1127 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
1128 .and_then(|(_, val)| *val)
1129 .and_then(|val| val.to_str())
1130 };
1131
1132 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
1133 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
1134 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
1135 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
1136 assert_eq!(env("npm_package_name"), Some("pkg"));
1137 assert_eq!(env("npm_package_version"), Some("1.2.3"));
1138 }
1139}
1140
1141#[cfg(all(test, windows))]
1142mod windows_quote_tests {
1143 use super::shell_quote_arg;
1144
1145 #[test]
1146 fn windows_path_backslash_not_doubled() {
1147 let q = shell_quote_arg(r"C:\Users\me\file.txt");
1148 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1149 }
1150
1151 #[test]
1152 fn windows_trailing_backslash_doubled_before_close_quote() {
1153 let q = shell_quote_arg(r"C:\path\");
1154 assert_eq!(q, "\"C:\\path\\\\\"");
1155 }
1156
1157 #[test]
1158 fn windows_quote_in_arg_escapes_with_backslash() {
1159 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1160 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1161 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1162 }
1163}