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
412fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
413 cmd.env_remove("AUBE_AUTH_TOKEN");
420 if let Some(node_options) = settings.node_options.as_deref() {
421 cmd.env("NODE_OPTIONS", node_options);
422 }
423 if let Some(unsafe_perm) = settings.unsafe_perm {
424 cmd.env(
425 "npm_config_unsafe_perm",
426 if unsafe_perm { "true" } else { "false" },
427 );
428 }
429 if settings.shell_emulator {
430 cmd.env("npm_config_shell_emulator", "true");
431 }
432}
433
434fn safe_jail_env_key(key: &str) -> bool {
435 const EXACT: &[&str] = &[
436 "PATH",
437 "HOME",
438 "TERM",
439 "LANG",
440 "LC_ALL",
441 "INIT_CWD",
442 "npm_lifecycle_event",
443 "npm_package_name",
444 "npm_package_version",
445 ];
446 if EXACT.contains(&key) {
447 return true;
448 }
449 let lower = key.to_ascii_lowercase();
450 if lower.contains("token")
451 || lower.contains("auth")
452 || lower.contains("password")
453 || lower.contains("credential")
454 || lower.contains("secret")
455 {
456 return false;
457 }
458 key.starts_with("npm_config_")
459}
460
461fn inherit_jail_env_key(key: &str, extra_env: &[String]) -> bool {
462 (safe_jail_env_key(key) || extra_env.iter().any(|env| env == key))
463 && !matches!(
464 key,
465 "PATH" | "HOME" | "npm_lifecycle_event" | "npm_package_name" | "npm_package_version"
466 )
467}
468
469fn jail_home(package_dir: &Path) -> PathBuf {
470 let mut hasher = DefaultHasher::new();
471 package_dir.hash(&mut hasher);
472 let hash = hasher.finish();
473 let name = package_dir
474 .file_name()
475 .and_then(|s| s.to_str())
476 .unwrap_or("package")
477 .chars()
478 .map(|c| {
479 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
480 c
481 } else {
482 '_'
483 }
484 })
485 .collect::<String>();
486 std::env::temp_dir()
487 .join("aube-jail")
488 .join(std::process::id().to_string())
489 .join(format!("{name}-{hash:016x}"))
490}
491
492fn apply_jail_env(
493 cmd: &mut tokio::process::Command,
494 path_env: &std::ffi::OsStr,
495 home: &Path,
496 project_root: &Path,
497 manifest: &PackageJson,
498 script_name: &str,
499 extra_env: &[String],
500) {
501 cmd.env_clear();
502 cmd.env("PATH", path_env)
503 .env("HOME", home)
504 .env("TMPDIR", home)
505 .env("TMP", home)
506 .env("TEMP", home)
507 .env("npm_lifecycle_event", script_name);
508 if std::env::var_os("INIT_CWD").is_none() {
509 cmd.env("INIT_CWD", project_root);
510 }
511 if let Some(ref name) = manifest.name {
512 cmd.env("npm_package_name", name);
513 }
514 if let Some(ref version) = manifest.version {
515 cmd.env("npm_package_version", version);
516 }
517 for (key, val) in std::env::vars_os() {
518 let Some(key_str) = key.to_str() else {
519 continue;
520 };
521 if inherit_jail_env_key(key_str, extra_env) {
522 cmd.env(key, val);
523 }
524 }
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum LifecycleHook {
532 PreInstall,
533 Install,
534 PostInstall,
535 Prepare,
536}
537
538impl LifecycleHook {
539 pub fn script_name(self) -> &'static str {
540 match self {
541 Self::PreInstall => "preinstall",
542 Self::Install => "install",
543 Self::PostInstall => "postinstall",
544 Self::Prepare => "prepare",
545 }
546 }
547}
548
549pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
553 LifecycleHook::PreInstall,
554 LifecycleHook::Install,
555 LifecycleHook::PostInstall,
556];
557
558#[cfg(unix)]
566static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
567
568#[cfg(unix)]
573pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
574 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
575}
576
577#[cfg(not(unix))]
582pub fn set_saved_stderr_fd(_fd: i32) {}
583
584#[cfg(unix)]
589pub fn child_stderr() -> std::process::Stdio {
590 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
591 if fd < 0 {
592 return std::process::Stdio::inherit();
593 }
594 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
599 match borrowed.try_clone_to_owned() {
600 Ok(owned) => std::process::Stdio::from(owned),
601 Err(_) => std::process::Stdio::inherit(),
602 }
603}
604
605#[cfg(not(unix))]
606pub fn child_stderr() -> std::process::Stdio {
607 std::process::Stdio::inherit()
608}
609
610#[allow(clippy::too_many_arguments)]
627pub async fn run_script(
628 script_dir: &Path,
629 project_root: &Path,
630 modules_dir_name: &str,
631 manifest: &PackageJson,
632 script_name: &str,
633 script_cmd: &str,
634 extra_bin_dirs: &[&Path],
635 jail: Option<&ScriptJail>,
636) -> Result<(), Error> {
637 let project_bin = project_root.join(modules_dir_name).join(".bin");
645 let path = std::env::var_os("PATH").unwrap_or_default();
646 let mut entries: Vec<PathBuf> = Vec::with_capacity(extra_bin_dirs.len() + 1);
647 for dir in extra_bin_dirs {
648 entries.push(dir.to_path_buf());
649 }
650 entries.push(project_bin);
651 entries.extend(std::env::split_paths(&path));
652 let new_path = std::env::join_paths(entries).unwrap_or(path);
653
654 let settings = script_settings();
655 let jail_home = jail.map(|j| jail_home(&j.package_dir));
656 if let Some(home) = &jail_home {
657 std::fs::create_dir_all(home)
658 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
659 }
660 let mut cmd = match (jail, jail_home.as_deref()) {
661 (Some(jail), Some(home)) => spawn_jailed_shell(script_cmd, &settings, jail, home),
662 _ => spawn_shell_with_settings(script_cmd, &settings),
663 };
664 cmd.current_dir(script_dir)
665 .stderr(child_stderr())
666 .env("PATH", &new_path)
667 .env("npm_lifecycle_event", script_name);
668
669 if std::env::var_os("INIT_CWD").is_none() {
676 cmd.env("INIT_CWD", project_root);
677 }
678
679 if let Some(ref name) = manifest.name {
680 cmd.env("npm_package_name", name);
681 }
682 if let Some(ref version) = manifest.version {
683 cmd.env("npm_package_version", version);
684 }
685 if let (Some(jail), Some(home)) = (jail, jail_home.as_deref()) {
686 apply_jail_env(
687 &mut cmd,
688 &new_path,
689 home,
690 project_root,
691 manifest,
692 script_name,
693 &jail.env,
694 );
695 apply_script_settings_env(&mut cmd, &settings);
696 }
697
698 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
699 let status = cmd
700 .status()
701 .await
702 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
703
704 if !status.success() {
705 return Err(Error::NonZeroExit {
706 script: script_name.to_string(),
707 code: status.code(),
708 });
709 }
710
711 Ok(())
712}
713
714pub async fn run_root_hook(
720 project_dir: &Path,
721 modules_dir_name: &str,
722 manifest: &PackageJson,
723 hook: LifecycleHook,
724) -> Result<bool, Error> {
725 run_root_script_by_name(project_dir, modules_dir_name, manifest, hook.script_name()).await
726}
727
728pub async fn run_root_script_by_name(
735 project_dir: &Path,
736 modules_dir_name: &str,
737 manifest: &PackageJson,
738 name: &str,
739) -> Result<bool, Error> {
740 let Some(script_cmd) = manifest.scripts.get(name) else {
741 return Ok(false);
742 };
743 run_script(
744 project_dir,
745 project_dir,
746 modules_dir_name,
747 manifest,
748 name,
749 script_cmd,
750 &[],
751 None,
752 )
753 .await?;
754 Ok(true)
755}
756
757pub fn implicit_install_script(
770 manifest: &PackageJson,
771 has_binding_gyp: bool,
772) -> Option<&'static str> {
773 if !has_binding_gyp {
774 return None;
775 }
776 if manifest
777 .scripts
778 .contains_key(LifecycleHook::Install.script_name())
779 || manifest
780 .scripts
781 .contains_key(LifecycleHook::PreInstall.script_name())
782 {
783 return None;
784 }
785 Some("node-gyp rebuild")
786}
787
788pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
792 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
793}
794
795pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
800 if DEP_LIFECYCLE_HOOKS
801 .iter()
802 .any(|h| manifest.scripts.contains_key(h.script_name()))
803 {
804 return true;
805 }
806 default_install_script(package_dir, manifest).is_some()
807}
808
809#[allow(clippy::too_many_arguments)]
839pub async fn run_dep_hook(
840 package_dir: &Path,
841 dep_modules_dir: &Path,
842 project_root: &Path,
843 modules_dir_name: &str,
844 manifest: &PackageJson,
845 hook: LifecycleHook,
846 tool_bin_dirs: &[&Path],
847 jail: Option<&ScriptJail>,
848) -> Result<bool, Error> {
849 let name = hook.script_name();
850 let script_cmd: &str = match manifest.scripts.get(name) {
851 Some(s) => s.as_str(),
852 None => match hook {
853 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
854 Some(s) => s,
855 None => return Ok(false),
856 },
857 _ => return Ok(false),
858 },
859 };
860 let dep_bin_dir = dep_modules_dir.join(".bin");
861 let mut bin_dirs: Vec<&Path> = Vec::with_capacity(tool_bin_dirs.len() + 1);
862 bin_dirs.push(&dep_bin_dir);
863 bin_dirs.extend(tool_bin_dirs.iter().copied());
864 run_script(
865 package_dir,
866 project_root,
867 modules_dir_name,
868 manifest,
869 name,
870 script_cmd,
871 &bin_dirs,
872 jail,
873 )
874 .await?;
875 Ok(true)
876}
877
878#[derive(Debug, thiserror::Error)]
879pub enum Error {
880 #[error("failed to spawn script {0}: {1}")]
881 Spawn(String, String),
882 #[error("script `{script}` exited with code {code:?}")]
883 NonZeroExit { script: String, code: Option<i32> },
884}
885
886#[cfg(test)]
887mod jail_tests {
888 use super::*;
889
890 #[test]
891 fn jail_home_uses_full_package_path() {
892 let a = jail_home(Path::new("/tmp/project/node_modules/@scope-a/native"));
893 let b = jail_home(Path::new("/tmp/project/node_modules/@scope-b/native"));
894
895 assert_ne!(a, b);
896 assert!(
897 a.file_name()
898 .unwrap()
899 .to_string_lossy()
900 .starts_with("native-")
901 );
902 assert!(
903 b.file_name()
904 .unwrap()
905 .to_string_lossy()
906 .starts_with("native-")
907 );
908 }
909
910 #[test]
911 fn jail_home_cleanup_removes_temp_home() {
912 let package_dir = std::env::temp_dir()
913 .join("aube-jail-cleanup-test")
914 .join(std::process::id().to_string())
915 .join("node_modules")
916 .join("native");
917 let jail = ScriptJail::new(&package_dir);
918 let home = jail_home(&package_dir);
919 std::fs::create_dir_all(home.join(".cache")).unwrap();
920 std::fs::write(home.join(".cache").join("marker"), "x").unwrap();
921
922 {
923 let _cleanup = ScriptJailHomeCleanup::new(&jail);
924 }
925
926 assert!(!home.exists());
927 }
928
929 #[test]
930 fn parent_env_cannot_override_explicit_jail_metadata() {
931 for key in [
932 "PATH",
933 "HOME",
934 "npm_lifecycle_event",
935 "npm_package_name",
936 "npm_package_version",
937 ] {
938 assert!(!inherit_jail_env_key(key, &[]));
939 }
940 assert!(inherit_jail_env_key("INIT_CWD", &[]));
941 assert!(inherit_jail_env_key("npm_config_arch", &[]));
942 assert!(!inherit_jail_env_key("npm_config__authToken", &[]));
943 assert!(inherit_jail_env_key(
944 "SHARP_DIST_BASE_URL",
945 &["SHARP_DIST_BASE_URL".to_string()]
946 ));
947 }
948
949 #[test]
950 fn jail_env_preserves_script_settings_after_clear() {
951 let mut cmd = tokio::process::Command::new("node");
952 let manifest = PackageJson {
953 name: Some("pkg".to_string()),
954 version: Some("1.2.3".to_string()),
955 ..Default::default()
956 };
957 let settings = ScriptSettings {
958 node_options: Some("--conditions=aube".to_string()),
959 unsafe_perm: Some(false),
960 shell_emulator: true,
961 ..Default::default()
962 };
963
964 apply_jail_env(
965 &mut cmd,
966 std::ffi::OsStr::new("/bin"),
967 Path::new("/tmp/aube-jail/home"),
968 Path::new("/tmp/project"),
969 &manifest,
970 "postinstall",
971 &[],
972 );
973 apply_script_settings_env(&mut cmd, &settings);
974
975 let envs = cmd.as_std().get_envs().collect::<Vec<_>>();
976 let env = |name: &str| {
977 envs.iter()
978 .find(|(key, _)| *key == std::ffi::OsStr::new(name))
979 .and_then(|(_, val)| *val)
980 .and_then(|val| val.to_str())
981 };
982
983 assert_eq!(env("NODE_OPTIONS"), Some("--conditions=aube"));
984 assert_eq!(env("npm_config_unsafe_perm"), Some("false"));
985 assert_eq!(env("npm_config_shell_emulator"), Some("true"));
986 assert_eq!(env("npm_lifecycle_event"), Some("postinstall"));
987 assert_eq!(env("npm_package_name"), Some("pkg"));
988 assert_eq!(env("npm_package_version"), Some("1.2.3"));
989 }
990}
991
992#[cfg(all(test, windows))]
993mod windows_quote_tests {
994 use super::shell_quote_arg;
995
996 #[test]
997 fn windows_path_backslash_not_doubled() {
998 let q = shell_quote_arg(r"C:\Users\me\file.txt");
999 assert_eq!(q, "\"C:\\Users\\me\\file.txt\"");
1000 }
1001
1002 #[test]
1003 fn windows_trailing_backslash_doubled_before_close_quote() {
1004 let q = shell_quote_arg(r"C:\path\");
1005 assert_eq!(q, "\"C:\\path\\\\\"");
1006 }
1007
1008 #[test]
1009 fn windows_quote_in_arg_escapes_with_backslash() {
1010 assert_eq!(shell_quote_arg(r#"a"b"#), "\"a\\\"b\"");
1011 assert_eq!(shell_quote_arg(r#"a\"b"#), "\"a\\\\\\\"b\"");
1012 assert_eq!(shell_quote_arg(r#"a\\"b"#), "\"a\\\\\\\\\\\"b\"");
1013 }
1014}