1use std::ffi::OsString;
57use std::io::{self, Read};
58use std::path::{Component, Path, PathBuf};
59
60pub fn create_dir_link(target: &Path, link: &Path) -> io::Result<()> {
66 #[cfg(unix)]
67 {
68 std::os::unix::fs::symlink(target, link)
69 }
70 #[cfg(windows)]
71 {
72 let abs_target = if target.is_absolute() {
73 target.to_path_buf()
74 } else {
75 let parent = link.parent().ok_or_else(|| {
76 io::Error::new(
77 io::ErrorKind::InvalidInput,
78 "junction link has no parent directory",
79 )
80 })?;
81 normalize_path(&parent.join(target))
82 };
83 create_junction_with_retry(&abs_target, link)
84 }
85 #[cfg(not(any(unix, windows)))]
86 {
87 let _ = (target, link);
88 Err(io::Error::new(
89 io::ErrorKind::Unsupported,
90 "directory links are not supported on this platform",
91 ))
92 }
93}
94
95#[cfg(windows)]
96fn create_junction_with_retry(target: &Path, link: &Path) -> io::Result<()> {
97 let mut attempt = 0;
98 let mut delay_ms = 50u64;
99 loop {
100 match junction::create(target, link) {
101 Ok(()) => return Ok(()),
102 Err(e) if is_retriable_link_error(&e) && attempt < 9 => {
103 std::thread::sleep(std::time::Duration::from_millis(delay_ms));
104 delay_ms = (delay_ms * 2).min(2000);
105 attempt += 1;
106 }
107 Err(e) => return Err(e),
108 }
109 }
110}
111
112#[cfg(windows)]
113fn is_retriable_link_error(error: &io::Error) -> bool {
114 matches!(error.raw_os_error(), Some(5 | 32))
115}
116
117#[derive(Debug, Clone, Copy, Default)]
122pub struct BinShimOptions<'a> {
123 pub extend_node_path: bool,
133 pub prefer_symlinked_executables: Option<bool>,
138 pub hidden_modules_dir: Option<&'a Path>,
147}
148
149#[derive(Debug, PartialEq, Eq)]
154pub struct ResolvedBinShim {
155 pub target: PathBuf,
156 pub node_path: Option<OsString>,
157}
158
159pub fn create_bin_shim(
179 bin_dir: &Path,
180 name: &str,
181 target: &Path,
182 opts: BinShimOptions<'_>,
183) -> io::Result<()> {
184 validate_bin_name(name)?;
185 #[cfg(unix)]
186 {
187 let write_shim = matches!(opts.prefer_symlinked_executables, Some(false));
188 let link_path = bin_dir.join(name);
189 let link_parent = link_path.parent().unwrap_or(bin_dir);
190 std::fs::create_dir_all(link_parent)?;
191 let _ = std::fs::remove_file(&link_path);
192 if write_shim {
193 let rel = relative_bin_target(link_parent, target);
194 let node_path = opts
195 .extend_node_path
196 .then(|| shim_node_path(link_parent, bin_dir, opts.hidden_modules_dir, "/", ":"));
197 let launch = detect_bin_launch(target);
198 std::fs::write(
199 &link_path,
200 generate_posix_shim(&launch, &rel, node_path.as_deref()),
201 )?;
202 use std::os::unix::fs::PermissionsExt;
203 std::fs::set_permissions(&link_path, std::fs::Permissions::from_mode(0o755))?;
204 if matches!(launch, BinLaunch::Direct) && target.exists() {
205 let _ = std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o755));
206 }
207 } else {
208 std::os::unix::fs::symlink(target, &link_path)?;
209 use std::os::unix::fs::PermissionsExt;
210 if target.exists() {
211 let _ = std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o755));
212 }
213 }
214 }
215 #[cfg(windows)]
216 {
217 let link_path = bin_dir.join(name);
218 let link_parent = link_path.parent().unwrap_or(bin_dir);
219 for p in win_shim_paths(bin_dir, name) {
224 if std::fs::remove_file(&p).is_err() {
225 let _ = std::fs::remove_dir(&p);
226 }
227 }
228 if let Err(e) = std::fs::create_dir_all(link_parent)
233 && e.kind() != std::io::ErrorKind::AlreadyExists
234 {
235 return Err(e);
236 }
237
238 let rel = relative_bin_target(link_parent, target);
239 let launch = detect_bin_launch(target);
240
241 let rel_backslash = rel.replace('/', "\\");
242 let rel_fwdslash = rel.replace('\\', "/");
243 let node_path_backslash = opts
250 .extend_node_path
251 .then(|| shim_node_path(link_parent, bin_dir, opts.hidden_modules_dir, "\\", ";"));
252 let node_path_fwdslash = opts
253 .extend_node_path
254 .then(|| shim_node_path(link_parent, bin_dir, opts.hidden_modules_dir, "/", ";"));
255
256 write_shim_file(
257 &bin_dir.join(format!("{name}.cmd")),
258 generate_cmd_shim(&launch, &rel_backslash, node_path_backslash.as_deref()).as_bytes(),
259 )?;
260 write_shim_file(
261 &bin_dir.join(format!("{name}.ps1")),
262 generate_ps1_shim(&launch, &rel_fwdslash, node_path_fwdslash.as_deref()).as_bytes(),
263 )?;
264 write_shim_file(
265 &bin_dir.join(name),
266 generate_sh_shim(&launch, &rel_fwdslash, node_path_fwdslash.as_deref()).as_bytes(),
267 )?;
268 }
269 #[cfg(not(any(unix, windows)))]
270 {
271 let _ = (bin_dir, name, target, opts);
272 return Err(io::Error::new(
273 io::ErrorKind::Unsupported,
274 "bin shims are not supported on this platform",
275 ));
276 }
277 Ok(())
278}
279
280pub fn validate_bin_name(name: &str) -> io::Result<()> {
286 if name.is_empty() || name.len() > 255 {
287 return Err(io::Error::new(
288 io::ErrorKind::InvalidInput,
289 format!("invalid bin name: {name:?}"),
290 ));
291 }
292 let parts: Vec<&str> = name.split('/').collect();
293 let ok = match parts.as_slice() {
294 [bare] => is_safe_bin_component(bare),
295 [scope, bare] => {
296 scope.starts_with('@')
297 && scope.len() > 1
298 && is_safe_bin_component(scope)
299 && is_safe_bin_component(bare)
300 }
301 _ => false,
302 };
303 if !ok {
304 return Err(io::Error::new(
305 io::ErrorKind::InvalidInput,
306 format!("invalid bin name: {name:?}"),
307 ));
308 }
309 Ok(())
310}
311
312pub fn validate_bin_target(rel: &str) -> io::Result<()> {
315 if rel.is_empty() || rel.contains('\0') || rel.contains('\\') {
316 return Err(io::Error::new(
317 io::ErrorKind::InvalidInput,
318 format!("invalid bin target: {rel:?}"),
319 ));
320 }
321 for ch in rel.chars() {
327 if matches!(
328 ch,
329 '$' | '`'
330 | '%'
331 | '"'
332 | '\''
333 | '&'
334 | '|'
335 | '^'
336 | ';'
337 | '<'
338 | '>'
339 | '('
340 | ')'
341 | '!'
342 | '*'
343 | '?'
344 ) || ch.is_control()
345 {
346 return Err(io::Error::new(
347 io::ErrorKind::InvalidInput,
348 format!("bin target contains shell metacharacter: {rel:?}"),
349 ));
350 }
351 }
352 let path = Path::new(rel);
353 if path.is_absolute()
354 || path.has_root()
355 || rel.starts_with('/')
356 || rel.len() >= 2 && rel.as_bytes()[1] == b':'
357 {
358 return Err(io::Error::new(
359 io::ErrorKind::InvalidInput,
360 format!("absolute bin target: {rel:?}"),
361 ));
362 }
363 for comp in path.components() {
364 match comp {
365 Component::Normal(_) | Component::CurDir => {}
366 _ => {
367 return Err(io::Error::new(
368 io::ErrorKind::InvalidInput,
369 format!("bin target escapes package: {rel:?}"),
370 ));
371 }
372 }
373 }
374 Ok(())
375}
376
377fn is_safe_bin_component(s: &str) -> bool {
378 if s.is_empty() || s == "." || s == ".." {
379 return false;
380 }
381 if s.bytes()
382 .any(|b| b == 0 || b == b'/' || b == b'\\' || b.is_ascii_control())
383 {
384 return false;
385 }
386 #[cfg(windows)]
395 {
396 if s.contains(':') || is_windows_reserved(s) || s.ends_with('.') || s.ends_with(' ') {
397 return false;
398 }
399 }
400 true
401}
402
403#[cfg(windows)]
404fn is_windows_reserved(s: &str) -> bool {
405 let stem = match s.find('.') {
406 Some(i) => &s[..i],
407 None => s,
408 };
409 let upper = stem.to_ascii_uppercase();
410 match upper.as_str() {
411 "CON" | "PRN" | "NUL" | "AUX" => true,
412 s if s.len() == 4
413 && (s.starts_with("COM") || s.starts_with("LPT"))
414 && s.as_bytes()[3].is_ascii_digit()
415 && s.as_bytes()[3] != b'0' =>
416 {
417 true
418 }
419 _ => false,
420 }
421}
422
423pub fn remove_bin_shim(bin_dir: &Path, name: &str) {
428 if validate_bin_name(name).is_err() {
429 return;
430 }
431 let link_path = bin_dir.join(name);
432 let _ = std::fs::remove_file(&link_path);
433 #[cfg(windows)]
434 for p in win_shim_paths(bin_dir, name).into_iter().skip(1) {
435 let _ = std::fs::remove_file(&p);
436 }
437 if let Some(parent) = link_path.parent()
438 && parent != bin_dir
439 {
440 let _ = std::fs::remove_dir(parent);
441 }
442}
443
444#[cfg(windows)]
449fn write_shim_file(dst: &Path, contents: &[u8]) -> io::Result<()> {
450 match std::fs::write(dst, contents) {
451 Ok(()) => Ok(()),
452 Err(e) if e.kind() == io::ErrorKind::AlreadyExists || e.raw_os_error() == Some(183) => {
453 let _ = std::fs::remove_file(dst);
458 let _ = std::fs::remove_dir(dst);
459 std::fs::write(dst, contents)
460 }
461 Err(e) => Err(e),
462 }
463}
464
465#[cfg(windows)]
471fn win_shim_paths(bin_dir: &Path, name: &str) -> [PathBuf; 3] {
472 [
473 bin_dir.join(name),
474 bin_dir.join(format!("{name}.cmd")),
475 bin_dir.join(format!("{name}.ps1")),
476 ]
477}
478
479fn relative_bin_target(base_dir: &Path, target: &Path) -> String {
493 let base = aube_util::path::strip_verbatim_prefix(base_dir);
494 let target = aube_util::path::strip_verbatim_prefix(target);
495 pathdiff::diff_paths(&target, &base)
496 .unwrap_or(target)
497 .to_string_lossy()
498 .replace('\\', "/")
499}
500
501fn shim_node_path(
517 link_parent: &Path,
518 bin_dir: &Path,
519 hidden_modules_dir: Option<&Path>,
520 path_sep: &str,
521 list_sep: &str,
522) -> String {
523 let (basedir_prefix, basedir_suffix) = if path_sep == "\\" {
524 ("%~dp0", "")
526 } else {
527 ("$basedir", "/")
528 };
529 let normalize = |rel: String| -> String {
530 if path_sep == "\\" {
531 rel.replace('/', "\\")
532 } else {
533 rel.replace('\\', "/")
534 }
535 };
536 let mut entries: Vec<String> = Vec::with_capacity(2);
537 let top = normalize(relative_bin_target(
538 link_parent,
539 bin_dir.parent().unwrap_or(bin_dir),
540 ));
541 entries.push(format!("{basedir_prefix}{basedir_suffix}{top}"));
542 if let Some(hidden) = hidden_modules_dir {
543 let rel = normalize(relative_bin_target(link_parent, hidden));
544 entries.push(format!("{basedir_prefix}{basedir_suffix}{rel}"));
545 }
546 entries.join(list_sep)
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
550enum BinLaunch {
551 Direct,
552 Interpreter(String),
553}
554
555fn detect_bin_launch(target: &Path) -> BinLaunch {
564 let mut buf = [0u8; 256];
565 let (n, target_exists) = match std::fs::File::open(target) {
566 Ok(mut file) => (file.read(&mut buf).unwrap_or(0), true),
567 Err(_) => (0, false),
568 };
569 let content = &buf[..n];
570 if n > 2
571 && content.starts_with(b"#!")
572 && let Some(line_end) = content.iter().position(|&b| b == b'\n')
573 {
574 let line = String::from_utf8_lossy(&content[2..line_end]);
575 let line = line.trim();
576 let prog = if let Some(rest) = line.strip_prefix("/usr/bin/env") {
578 let rest = rest.trim_start();
579 let rest = rest.strip_prefix("-S").map_or(rest, |r| r.trim_start());
580 rest.split_whitespace()
582 .find(|s| !s.contains('='))
583 .unwrap_or("node")
584 } else {
585 line.split_whitespace()
587 .next()
588 .and_then(|p| p.rsplit('/').next())
589 .unwrap_or("node")
590 };
591 if is_safe_prog(prog) {
600 return BinLaunch::Interpreter(prog.to_string());
601 }
602 tracing::warn!("ignoring unsafe shebang interpreter in {target:?}: {prog:?}");
608 }
609 default_launch_for_target(
610 target,
611 content,
612 target_exists && !content.starts_with(b"#!"),
613 )
614}
615
616fn is_safe_prog(prog: &str) -> bool {
623 if prog.is_empty() || prog.len() > 64 {
624 return false;
625 }
626 let mut chars = prog.chars();
632 match chars.next() {
633 Some(c) if c.is_ascii_alphanumeric() => {}
634 _ => return false,
635 }
636 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
637}
638
639fn default_launch_for_target(target: &Path, content: &[u8], allow_direct: bool) -> BinLaunch {
640 match target.extension().and_then(|e| e.to_str()) {
641 Some("js" | "cjs" | "mjs") => BinLaunch::Interpreter("node".to_string()),
642 Some("cmd" | "bat") => BinLaunch::Interpreter("cmd".to_string()),
643 Some("ps1") => BinLaunch::Interpreter("pwsh".to_string()),
644 Some("sh") => BinLaunch::Interpreter("sh".to_string()),
645 Some(ext) if allow_direct && ext.eq_ignore_ascii_case("exe") => BinLaunch::Direct,
646 _ if allow_direct && has_native_executable_magic(content) => BinLaunch::Direct,
647 _ => BinLaunch::Interpreter("node".to_string()),
648 }
649}
650
651fn has_native_executable_magic(content: &[u8]) -> bool {
652 const FOUR_BYTE_MAGICS: [[u8; 4]; 9] = [
653 *b"\x7fELF",
654 [0xfe, 0xed, 0xfa, 0xce],
655 [0xce, 0xfa, 0xed, 0xfe],
656 [0xfe, 0xed, 0xfa, 0xcf],
657 [0xcf, 0xfa, 0xed, 0xfe],
658 [0xca, 0xfe, 0xba, 0xbe],
659 [0xbe, 0xba, 0xfe, 0xca],
660 [0xca, 0xfe, 0xba, 0xbf],
661 [0xbf, 0xba, 0xfe, 0xca],
662 ];
663 content.starts_with(b"MZ")
664 || content
665 .get(..4)
666 .is_some_and(|magic| FOUR_BYTE_MAGICS.iter().any(|candidate| magic == candidate))
667}
668
669fn safe_prog(prog: &str) -> &str {
677 if is_safe_prog(prog) {
678 prog
679 } else {
680 tracing::error!(
681 code = aube_codes::errors::ERR_AUBE_UNSAFE_SHEBANG_INTERPRETER,
682 "refusing to splice unsafe prog {prog:?} into shim, substituting \"node\""
683 );
684 "node"
685 }
686}
687
688#[cfg(windows)]
689fn generate_cmd_shim(
690 launch: &BinLaunch,
691 rel_target_backslash: &str,
692 node_path_value: Option<&str>,
693) -> String {
694 if matches!(launch, BinLaunch::Direct) {
695 let node_path =
696 node_path_value.map_or(String::new(), |val| format!("@SET NODE_PATH={val}\r\n"));
697 return format!(
698 "@SETLOCAL\r\n\
699 {node_path}\
700 @\"%~dp0\\{rel_target_backslash}\" %*\r\n"
701 );
702 }
703 let BinLaunch::Interpreter(prog) = launch else {
704 unreachable!();
705 };
706 let prog = safe_prog(prog);
707 let node_path =
708 node_path_value.map_or(String::new(), |val| format!("@SET NODE_PATH={val}\r\n"));
709 format!(
710 "@SETLOCAL\r\n\
711 {node_path}\
712 @IF EXIST \"%~dp0\\{prog}.exe\" (\r\n\
713 \x20 \"%~dp0\\{prog}.exe\" \"%~dp0\\{rel_target_backslash}\" %*\r\n\
714 ) ELSE (\r\n\
715 \x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\
716 \x20 {prog} \"%~dp0\\{rel_target_backslash}\" %*\r\n\
717 )\r\n"
718 )
719}
720
721#[cfg(windows)]
722fn generate_ps1_shim(
723 launch: &BinLaunch,
724 rel_target_fwdslash: &str,
725 node_path_value: Option<&str>,
726) -> String {
727 if matches!(launch, BinLaunch::Direct) {
728 let node_path =
729 node_path_value.map_or(String::new(), |val| format!("$env:NODE_PATH=\"{val}\"\n"));
730 return format!(
731 "#!/usr/bin/env pwsh\n\
732 $basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\
733 {node_path}\
734 $ret=0\n\
735 if ($MyInvocation.ExpectingInput) {{\n\
736 \x20 $input | & \"$basedir/{rel_target_fwdslash}\" $args\n\
737 }} else {{\n\
738 \x20 & \"$basedir/{rel_target_fwdslash}\" $args\n\
739 }}\n\
740 $ret=$LASTEXITCODE\n\
741 exit $ret\n"
742 );
743 }
744 let BinLaunch::Interpreter(prog) = launch else {
745 unreachable!();
746 };
747 let prog = safe_prog(prog);
748 let node_path =
749 node_path_value.map_or(String::new(), |val| format!("$env:NODE_PATH=\"{val}\"\n"));
750 format!(
751 "#!/usr/bin/env pwsh\n\
752 $basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\
753 \n\
754 {node_path}\
755 $exe=\"\"\n\
756 if ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {{\n\
757 \x20 $exe=\".exe\"\n\
758 }}\n\
759 $ret=0\n\
760 if (Test-Path \"$basedir/{prog}$exe\") {{\n\
761 \x20 if ($MyInvocation.ExpectingInput) {{\n\
762 \x20\x20\x20 $input | & \"$basedir/{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
763 \x20 }} else {{\n\
764 \x20\x20\x20 & \"$basedir/{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
765 \x20 }}\n\
766 \x20 $ret=$LASTEXITCODE\n\
767 }} else {{\n\
768 \x20 if ($MyInvocation.ExpectingInput) {{\n\
769 \x20\x20\x20 $input | & \"{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
770 \x20 }} else {{\n\
771 \x20\x20\x20 & \"{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
772 \x20 }}\n\
773 \x20 $ret=$LASTEXITCODE\n\
774 }}\n\
775 exit $ret\n"
776 )
777}
778
779#[cfg(windows)]
780fn generate_sh_shim(
781 launch: &BinLaunch,
782 rel_target_fwdslash: &str,
783 node_path_value: Option<&str>,
784) -> String {
785 if matches!(launch, BinLaunch::Direct) {
786 let node_path =
787 node_path_value.map_or(String::new(), |val| format!("export NODE_PATH=\"{val}\"\n"));
788 return format!(
789 "#!/bin/sh\n\
790 basedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\
791 \n\
792 case `uname` in\n\
793 \x20\x20\x20 *CYGWIN*|*MINGW*|*MSYS*)\n\
794 \x20\x20\x20\x20\x20\x20\x20 if command -v cygpath > /dev/null 2>&1; then\n\
795 \x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 basedir=`cygpath -w \"$basedir\"`\n\
796 \x20\x20\x20\x20\x20\x20\x20 fi\n\
797 \x20\x20\x20 ;;\n\
798 esac\n\
799 \n\
800 {node_path}\
801 exec \"$basedir/{rel_target_fwdslash}\" \"$@\"\n"
802 );
803 }
804 let BinLaunch::Interpreter(prog) = launch else {
805 unreachable!();
806 };
807 let prog = safe_prog(prog);
808 let node_path =
809 node_path_value.map_or(String::new(), |val| format!("export NODE_PATH=\"{val}\"\n"));
810 format!(
811 "#!/bin/sh\n\
812 basedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\
813 \n\
814 case `uname` in\n\
815 \x20\x20\x20 *CYGWIN*|*MINGW*|*MSYS*)\n\
816 \x20\x20\x20\x20\x20\x20\x20 if command -v cygpath > /dev/null 2>&1; then\n\
817 \x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 basedir=`cygpath -w \"$basedir\"`\n\
818 \x20\x20\x20\x20\x20\x20\x20 fi\n\
819 \x20\x20\x20 ;;\n\
820 esac\n\
821 \n\
822 {node_path}\
823 if [ -x \"$basedir/{prog}\" ]; then\n\
824 \x20 exec \"$basedir/{prog}\" \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
825 else\n\
826 \x20 exec {prog} \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
827 fi\n"
828 )
829}
830
831pub const POSIX_SHIM_MARKER_PREFIX: &str = "# aube-bin-shim v2 target=";
838
839const POSIX_SHIM_BASEDIR: &str = "link=\"$0\"\n\
843hops=0\n\
844while [ -L \"$link\" ] && [ \"$hops\" -lt 40 ]; do\n\
845 hops=$((hops+1))\n\
846 target=$(readlink \"$link\")\n\
847 case \"$target\" in\n\
848 /*) link=\"$target\" ;;\n\
849 *) link=\"$(dirname \"$link\")/$target\" ;;\n\
850 esac\n\
851done\n\
852basedir=$(dirname \"$link\")\n";
853
854#[cfg(unix)]
861fn generate_posix_shim(
862 launch: &BinLaunch,
863 rel_target_fwdslash: &str,
864 node_path_value: Option<&str>,
865) -> String {
866 let node_path =
867 node_path_value.map_or(String::new(), |val| format!("export NODE_PATH=\"{val}\"\n"));
868 if matches!(launch, BinLaunch::Direct) {
869 return format!(
870 "#!/bin/sh\n\
871 {POSIX_SHIM_MARKER_PREFIX}{rel_target_fwdslash}\n\
872 {POSIX_SHIM_BASEDIR}\
873 {node_path}\
874 exec \"$basedir/{rel_target_fwdslash}\" \"$@\"\n"
875 );
876 }
877 let BinLaunch::Interpreter(prog) = launch else {
878 unreachable!();
879 };
880 let prog = safe_prog(prog);
881 format!(
882 "#!/bin/sh\n\
883 {POSIX_SHIM_MARKER_PREFIX}{rel_target_fwdslash}\n\
884 {POSIX_SHIM_BASEDIR}\
885 {node_path}\
886 if [ -x \"$basedir/{prog}\" ]; then\n\
887 \x20 exec \"$basedir/{prog}\" \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
888 else\n\
889 \x20 exec {prog} \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
890 fi\n"
891 )
892}
893
894pub fn parse_posix_shim_target(content: &str) -> Option<&str> {
901 for line in content.lines() {
902 if let Some(rest) = line.strip_prefix(POSIX_SHIM_MARKER_PREFIX) {
903 return Some(rest);
904 }
905 }
906 None
907}
908
909const MAX_BIN_SHIM_BYTES: u64 = 64 * 1024;
913
914#[derive(Clone, Copy)]
915enum BinShimStyle {
916 Posix,
917 Cmd,
918}
919
920pub fn resolve_bin_shim(path: &Path) -> io::Result<Option<ResolvedBinShim>> {
927 let metadata = std::fs::symlink_metadata(path)?;
928 if !metadata.file_type().is_file() || metadata.len() > MAX_BIN_SHIM_BYTES {
929 return Ok(None);
930 }
931
932 let mut bytes = Vec::with_capacity(metadata.len() as usize);
933 std::fs::File::open(path)?
934 .take(MAX_BIN_SHIM_BYTES + 1)
935 .read_to_end(&mut bytes)?;
936 if bytes.len() as u64 > MAX_BIN_SHIM_BYTES {
937 return Ok(None);
938 }
939 let Ok(content) = std::str::from_utf8(&bytes) else {
940 return Ok(None);
941 };
942 let Some(parent) = path.parent() else {
943 return Ok(None);
944 };
945
946 let parsed = if let Some(target) = parse_posix_shim_target(content) {
947 Some((
948 BinShimStyle::Posix,
949 target,
950 content.lines().find_map(|line| {
951 line.strip_prefix("export NODE_PATH=\"")
952 .and_then(|value| value.strip_suffix('"'))
953 }),
954 ))
955 } else {
956 parse_cmd_shim_target(content).map(|target| {
957 (
958 BinShimStyle::Cmd,
959 target,
960 content
961 .lines()
962 .find_map(|line| line.strip_prefix("@SET NODE_PATH="))
963 .map(|value| value.trim_end_matches('\r')),
964 )
965 })
966 };
967 let Some((style, target, raw_node_path)) = parsed else {
968 return Ok(None);
969 };
970 let Some(target) = resolve_shim_relative_path(parent, target, style) else {
971 return Ok(None);
972 };
973
974 let node_path = match raw_node_path {
975 Some(value) => {
976 let Some(node_path) = resolve_shim_node_path(parent, value, style) else {
977 return Ok(None);
978 };
979 Some(node_path)
980 }
981 None => None,
982 };
983
984 Ok(Some(ResolvedBinShim { target, node_path }))
985}
986
987fn parse_cmd_shim_target(content: &str) -> Option<&str> {
988 let mut lines = content.lines();
989 if lines.next()?.trim_end_matches('\r') != "@SETLOCAL" {
990 return None;
991 }
992
993 let mut line = lines.next()?.trim_end_matches('\r');
994 if line.starts_with("@SET NODE_PATH=") {
995 line = lines.next()?.trim_end_matches('\r');
996 }
997
998 let if_prefix = "@IF EXIST \"%~dp0\\";
999 let program = line.strip_prefix(if_prefix)?.strip_suffix(".exe\" (")?;
1000 if !is_safe_prog(program) {
1001 return None;
1002 }
1003
1004 let local_line = lines.next()?.trim_end_matches('\r');
1005 let target = local_line
1006 .strip_prefix(" \"%~dp0\\")?
1007 .strip_prefix(program)?
1008 .strip_prefix(".exe\" \"%~dp0\\")?
1009 .strip_suffix("\" %*")?;
1010 if lines.next()?.trim_end_matches('\r') != ") ELSE ("
1011 || lines.next()?.trim_end_matches('\r') != " @SET PATHEXT=%PATHEXT:;.JS;=;%"
1012 {
1013 return None;
1014 }
1015
1016 let fallback_target = lines
1017 .next()?
1018 .trim_end_matches('\r')
1019 .strip_prefix(" ")?
1020 .strip_prefix(program)?
1021 .strip_prefix(" \"%~dp0\\")?
1022 .strip_suffix("\" %*")?;
1023 if fallback_target != target || lines.next()?.trim_end_matches('\r') != ")" {
1024 return None;
1025 }
1026 lines.next().is_none().then_some(target)
1027}
1028
1029fn resolve_shim_relative_path(
1030 parent: &Path,
1031 relative: &str,
1032 style: BinShimStyle,
1033) -> Option<PathBuf> {
1034 if relative.is_empty()
1035 || relative.contains('\0')
1036 || relative.starts_with('/')
1037 || relative.starts_with('\\')
1038 || relative.len() >= 2 && relative.as_bytes()[1] == b':'
1039 {
1040 return None;
1041 }
1042 let relative = match style {
1043 BinShimStyle::Posix => relative.to_string(),
1044 BinShimStyle::Cmd => relative.replace('\\', std::path::MAIN_SEPARATOR_STR),
1045 };
1046 Some(normalize_path(&parent.join(relative)))
1047}
1048
1049fn resolve_shim_node_path(parent: &Path, value: &str, style: BinShimStyle) -> Option<OsString> {
1050 if matches!(style, BinShimStyle::Posix) && value.contains(';') {
1053 return None;
1054 }
1055 let (separator, prefix) = match style {
1056 BinShimStyle::Posix => (':', "$basedir/"),
1057 BinShimStyle::Cmd => (';', "%~dp0"),
1058 };
1059 let paths = value
1060 .split(separator)
1061 .map(|entry| {
1062 let relative = entry.strip_prefix(prefix)?;
1063 resolve_shim_relative_path(parent, relative, style)
1064 })
1065 .collect::<Option<Vec<_>>>()?;
1066 std::env::join_paths(paths).ok()
1067}
1068
1069pub fn normalize_path(path: &Path) -> PathBuf {
1079 let mut out: Vec<Component> = Vec::new();
1080 for comp in path.components() {
1081 match comp {
1082 Component::ParentDir => {
1083 if !matches!(
1084 out.last(),
1085 None | Some(Component::RootDir) | Some(Component::Prefix(_))
1086 ) {
1087 out.pop();
1088 } else {
1089 out.push(comp);
1090 }
1091 }
1092 Component::CurDir => {}
1093 other => out.push(other),
1094 }
1095 }
1096 out.iter().map(|c| c.as_os_str()).collect()
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 #[test]
1104 fn validate_bin_name_accepts_bare_and_scope() {
1105 assert!(validate_bin_name("foo").is_ok());
1106 assert!(validate_bin_name("foo-bar.js").is_ok());
1107 assert!(validate_bin_name("@scope/foo").is_ok());
1108 }
1109
1110 #[test]
1111 fn validate_bin_name_rejects_traversal_and_separators() {
1112 for bad in [
1113 "",
1114 "..",
1115 ".",
1116 "../../../etc/passwd",
1117 "a/b/c",
1118 "a\\b",
1119 "foo\0",
1120 "/etc/cron.d/evil",
1121 "\\\\server\\share\\x",
1122 "C:\\x",
1123 "@scope/../x",
1124 "@/foo",
1125 "scope/foo",
1126 ] {
1127 assert!(validate_bin_name(bad).is_err(), "should reject {bad:?}");
1128 }
1129 }
1130
1131 #[test]
1132 fn validate_bin_target_rejects_shell_metacharacters() {
1133 for bad in [
1134 "bin/$(calc).js",
1135 "bin/$env:USERPROFILE.js",
1136 "bin/`id`.js",
1137 "bin/%PATH%.js",
1138 "bin/foo&bar.js",
1139 "bin/foo|bar.js",
1140 "bin/foo;bar.js",
1141 "bin/foo>bar.js",
1142 "bin/foo<bar.js",
1143 "bin/foo\"bar.js",
1144 "bin/foo'bar.js",
1145 "bin/foo!bar.js",
1146 ] {
1147 assert!(
1148 validate_bin_target(bad).is_err(),
1149 "must reject shell metachar payload {bad:?}"
1150 );
1151 }
1152 }
1153
1154 #[test]
1155 fn validate_bin_target_rejects_absolute_and_traversal() {
1156 assert!(validate_bin_target("bin/cli.js").is_ok());
1157 assert!(validate_bin_target("./cli.js").is_ok());
1158 for bad in [
1159 "",
1160 "/etc/passwd",
1161 "../../../etc/passwd",
1162 "bin/../../../etc/passwd",
1163 "C:/Windows/x",
1164 "bin\\cli.js",
1165 "cli\0.js",
1166 ] {
1167 assert!(validate_bin_target(bad).is_err(), "should reject {bad:?}");
1168 }
1169 }
1170
1171 #[test]
1172 fn create_bin_shim_rejects_traversing_name() {
1173 let dir = tempfile::tempdir().unwrap();
1174 let bin_dir = dir.path().join(".bin");
1175 std::fs::create_dir_all(&bin_dir).unwrap();
1176 let target = dir.path().join("cli.js");
1177 std::fs::write(&target, "#!/usr/bin/env node\n").unwrap();
1178 let err = create_bin_shim(
1179 &bin_dir,
1180 "../../../evil",
1181 &target,
1182 BinShimOptions::default(),
1183 )
1184 .unwrap_err();
1185 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1186 }
1187
1188 #[test]
1189 fn detect_interpreter_shebang_env_node() {
1190 let dir = tempfile::tempdir().unwrap();
1191 let script = dir.path().join("cli.js");
1192 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1193 assert_eq!(
1194 detect_bin_launch(&script),
1195 BinLaunch::Interpreter("node".to_string())
1196 );
1197 }
1198
1199 #[test]
1200 fn detect_interpreter_shebang_env_with_s_flag() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let script = dir.path().join("cli.js");
1203 std::fs::write(
1204 &script,
1205 "#!/usr/bin/env -S node --harmony\nconsole.log('hi');\n",
1206 )
1207 .unwrap();
1208 assert_eq!(
1209 detect_bin_launch(&script),
1210 BinLaunch::Interpreter("node".to_string())
1211 );
1212 }
1213
1214 #[test]
1215 fn detect_interpreter_shebang_absolute_path() {
1216 let dir = tempfile::tempdir().unwrap();
1217 let script = dir.path().join("cli.js");
1218 std::fs::write(&script, "#!/usr/bin/node\nconsole.log('hi');\n").unwrap();
1219 assert_eq!(
1220 detect_bin_launch(&script),
1221 BinLaunch::Interpreter("node".to_string())
1222 );
1223 }
1224
1225 #[test]
1226 fn detect_interpreter_shebang_env_python() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let script = dir.path().join("cli.py");
1229 std::fs::write(&script, "#!/usr/bin/env python3\nprint('hi')\n").unwrap();
1230 assert_eq!(
1231 detect_bin_launch(&script),
1232 BinLaunch::Interpreter("python3".to_string())
1233 );
1234 }
1235
1236 #[test]
1237 fn detect_interpreter_shebang_with_env_vars() {
1238 let dir = tempfile::tempdir().unwrap();
1239 let script = dir.path().join("cli.js");
1240 std::fs::write(
1241 &script,
1242 "#!/usr/bin/env NODE_OPTIONS=--max-old-space-size=4096 node\nconsole.log('hi');\n",
1243 )
1244 .unwrap();
1245 assert_eq!(
1246 detect_bin_launch(&script),
1247 BinLaunch::Interpreter("node".to_string())
1248 );
1249 }
1250
1251 #[test]
1252 fn detect_interpreter_no_shebang_js() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let script = dir.path().join("cli.js");
1255 std::fs::write(&script, "console.log('hi');\n").unwrap();
1256 assert_eq!(
1257 detect_bin_launch(&script),
1258 BinLaunch::Interpreter("node".to_string())
1259 );
1260 }
1261
1262 #[test]
1263 fn detect_interpreter_nonexistent_file_defaults_to_node() {
1264 assert_eq!(
1265 detect_bin_launch(Path::new("/nonexistent/file.js")),
1266 BinLaunch::Interpreter("node".to_string())
1267 );
1268 }
1269
1270 #[test]
1271 fn detect_launch_uses_direct_mode_for_no_shebang_native_target() {
1272 let dir = tempfile::tempdir().unwrap();
1273 let target = dir.path().join("native.exe");
1274 std::fs::write(&target, b"\x7fELF").unwrap();
1275 assert_eq!(detect_bin_launch(&target), BinLaunch::Direct);
1276 }
1277
1278 #[test]
1279 fn detect_launch_uses_direct_mode_for_extensionless_native_target() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let target = dir.path().join("native");
1282 std::fs::write(&target, b"\xcf\xfa\xed\xfe").unwrap();
1283 assert_eq!(detect_bin_launch(&target), BinLaunch::Direct);
1284 }
1285
1286 #[test]
1287 fn detect_launch_keeps_extensionless_javascript_on_node() {
1288 let dir = tempfile::tempdir().unwrap();
1289 let target = dir.path().join("cli");
1290 std::fs::write(&target, b"console.log('hi')\n").unwrap();
1291 assert_eq!(
1292 detect_bin_launch(&target),
1293 BinLaunch::Interpreter("node".to_string())
1294 );
1295 }
1296
1297 #[test]
1298 fn detect_launch_keeps_unknown_text_extension_on_node() {
1299 let dir = tempfile::tempdir().unwrap();
1300 let target = dir.path().join("cli.custom");
1301 std::fs::write(&target, b"console.log('hi')\n").unwrap();
1302 assert_eq!(
1303 detect_bin_launch(&target),
1304 BinLaunch::Interpreter("node".to_string())
1305 );
1306 }
1307
1308 #[test]
1309 fn relative_bin_target_computes_path() {
1310 let bin_dir = Path::new("/project/node_modules/.bin");
1311 let target =
1312 Path::new("/project/node_modules/.aube/is-odd@3.0.1/node_modules/is-odd/cli.js");
1313 let rel = relative_bin_target(bin_dir, target);
1314 assert_eq!(rel, "../.aube/is-odd@3.0.1/node_modules/is-odd/cli.js");
1315 }
1316
1317 #[cfg(windows)]
1318 #[test]
1319 fn relative_bin_target_strips_verbatim_prefix_from_target() {
1320 let base = Path::new(r"C:\pkg\bin");
1328 let target = Path::new(r"\\?\C:\pkg\global-aube\abc\node_modules\p\bin\p.cjs");
1329 let rel = relative_bin_target(base, target);
1330 assert_eq!(rel, "../global-aube/abc/node_modules/p/bin/p.cjs");
1331 }
1332
1333 #[cfg(windows)]
1334 #[test]
1335 fn relative_bin_target_strips_verbatim_prefix_from_base() {
1336 let base = Path::new(r"\\?\C:\pkg\bin");
1337 let target = Path::new(r"C:\pkg\global-aube\abc\node_modules\p\bin\p.cjs");
1338 let rel = relative_bin_target(base, target);
1339 assert_eq!(rel, "../global-aube/abc/node_modules/p/bin/p.cjs");
1340 }
1341
1342 #[cfg(windows)]
1343 #[test]
1344 fn relative_bin_target_preserves_unc_share_prefix() {
1345 let base = Path::new(r"\\?\UNC\server\share\pkg\bin");
1350 let target = Path::new(r"\\?\UNC\server\share\pkg\lib\cli.js");
1351 let rel = relative_bin_target(base, target);
1352 assert_eq!(rel, "../lib/cli.js");
1353 }
1354
1355 #[cfg(windows)]
1356 #[test]
1357 fn normalize_collapses_parent_and_cur_dir() {
1358 let p = Path::new(r"C:\a\b\.\..\c\d\..\e");
1359 assert_eq!(normalize_path(p), PathBuf::from(r"C:\a\c\e"));
1360 }
1361
1362 #[cfg(windows)]
1363 #[test]
1364 fn creates_junction_without_developer_mode() {
1365 let dir = tempfile::tempdir().unwrap();
1366 let target = dir.path().join("target");
1367 std::fs::create_dir(&target).unwrap();
1368 std::fs::write(target.join("marker.txt"), b"hi").unwrap();
1369
1370 let link = dir.path().join("parent").join("link");
1371 std::fs::create_dir_all(link.parent().unwrap()).unwrap();
1372 let rel = Path::new("..").join("target");
1374 create_dir_link(&rel, &link).unwrap();
1375
1376 assert_eq!(std::fs::read(link.join("marker.txt")).unwrap(), b"hi");
1377 }
1378
1379 #[cfg(windows)]
1380 #[test]
1381 fn create_bin_shim_writes_three_files() {
1382 let dir = tempfile::tempdir().unwrap();
1383 let bin_dir = dir.path().join("node_modules/.bin");
1384 std::fs::create_dir_all(&bin_dir).unwrap();
1385
1386 let pkg_dir = dir
1387 .path()
1388 .join("node_modules/.aube/is-odd@3.0.1/node_modules/is-odd");
1389 std::fs::create_dir_all(&pkg_dir).unwrap();
1390 let script = pkg_dir.join("cli.js");
1391 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1392
1393 create_bin_shim(&bin_dir, "is-odd", &script, BinShimOptions::default()).unwrap();
1394
1395 assert!(bin_dir.join("is-odd.cmd").exists());
1397 assert!(bin_dir.join("is-odd.ps1").exists());
1398 assert!(bin_dir.join("is-odd").exists());
1399
1400 let cmd = std::fs::read_to_string(bin_dir.join("is-odd.cmd")).unwrap();
1402 assert!(cmd.contains("node.exe"));
1403 assert!(cmd.contains(".aube"));
1404
1405 let ps1 = std::fs::read_to_string(bin_dir.join("is-odd.ps1")).unwrap();
1407 assert!(ps1.contains("node$exe"));
1408
1409 let sh = std::fs::read_to_string(bin_dir.join("is-odd")).unwrap();
1411 assert!(sh.starts_with("#!/bin/sh"));
1412 }
1413
1414 #[cfg(windows)]
1415 #[test]
1416 fn create_bin_shim_cleans_old_files() {
1417 let dir = tempfile::tempdir().unwrap();
1418 let bin_dir = dir.path().join("node_modules/.bin");
1419 std::fs::create_dir_all(&bin_dir).unwrap();
1420
1421 let pkg_dir = dir.path().join("pkg");
1422 std::fs::create_dir_all(&pkg_dir).unwrap();
1423 let script = pkg_dir.join("cli.js");
1424 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('v1');\n").unwrap();
1425
1426 create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
1428 let cmd1 = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
1429
1430 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('v2');\n").unwrap();
1432 create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
1433 let cmd2 = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
1434
1435 assert_eq!(cmd1, cmd2);
1437 }
1438
1439 #[cfg(windows)]
1440 #[test]
1441 fn remove_bin_shim_removes_all_files() {
1442 let dir = tempfile::tempdir().unwrap();
1443 let bin_dir = dir.path().join("node_modules/.bin");
1444 std::fs::create_dir_all(&bin_dir).unwrap();
1445
1446 let pkg_dir = dir.path().join("pkg");
1447 std::fs::create_dir_all(&pkg_dir).unwrap();
1448 let script = pkg_dir.join("cli.js");
1449 std::fs::write(&script, "console.log('hi');\n").unwrap();
1450
1451 create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
1452 assert!(bin_dir.join("mycli.cmd").exists());
1453 assert!(bin_dir.join("mycli.ps1").exists());
1454 assert!(bin_dir.join("mycli").exists());
1455
1456 remove_bin_shim(&bin_dir, "mycli");
1457 assert!(!bin_dir.join("mycli.cmd").exists());
1458 assert!(!bin_dir.join("mycli.ps1").exists());
1459 assert!(!bin_dir.join("mycli").exists());
1460 }
1461
1462 #[cfg(unix)]
1463 #[test]
1464 fn create_bin_shim_creates_symlink_on_unix() {
1465 let dir = tempfile::tempdir().unwrap();
1466 let bin_dir = dir.path().join("node_modules/.bin");
1467 std::fs::create_dir_all(&bin_dir).unwrap();
1468
1469 let pkg_dir = dir.path().join("pkg");
1470 std::fs::create_dir_all(&pkg_dir).unwrap();
1471 let script = pkg_dir.join("cli.js");
1472 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1473
1474 create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
1475
1476 let link = bin_dir.join("mycli");
1477 assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
1478
1479 use std::os::unix::fs::PermissionsExt;
1481 let mode = std::fs::metadata(&script).unwrap().permissions().mode();
1482 assert_eq!(mode & 0o755, 0o755);
1483 }
1484
1485 #[test]
1486 #[cfg(unix)]
1487 fn create_bin_shim_creates_parent_for_scoped_bin_name() {
1488 let dir = tempfile::tempdir().unwrap();
1489 let bin_dir = dir.path().join("node_modules/.bin");
1490 std::fs::create_dir_all(&bin_dir).unwrap();
1491
1492 let pkg_dir = dir.path().join(
1493 "node_modules/.aube/config-inspector@1.4.2/node_modules/@eslint/config-inspector",
1494 );
1495 std::fs::create_dir_all(&pkg_dir).unwrap();
1496 let script = pkg_dir.join("bin.mjs");
1497 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1498
1499 create_bin_shim(
1500 &bin_dir,
1501 "@eslint/config-inspector",
1502 &script,
1503 BinShimOptions {
1504 extend_node_path: true,
1505 prefer_symlinked_executables: Some(false),
1506 hidden_modules_dir: None,
1507 },
1508 )
1509 .unwrap();
1510
1511 let shim_path = bin_dir.join("@eslint/config-inspector");
1512 assert!(shim_path.exists());
1513 let content = std::fs::read_to_string(shim_path).unwrap();
1514 let rel = parse_posix_shim_target(&content).expect("shim should carry its marker");
1515 assert_eq!(
1516 rel,
1517 "../../.aube/config-inspector@1.4.2/node_modules/@eslint/config-inspector/bin.mjs",
1518 );
1519 assert!(content.contains("export NODE_PATH=\"$basedir/../..\""));
1520 }
1521
1522 #[test]
1523 fn remove_bin_shim_removes_empty_scoped_parent_dir() {
1524 let dir = tempfile::tempdir().unwrap();
1525 let bin_dir = dir.path().join("node_modules/.bin");
1526 std::fs::create_dir_all(&bin_dir).unwrap();
1527
1528 let pkg_dir = dir.path().join("pkg");
1529 std::fs::create_dir_all(&pkg_dir).unwrap();
1530 let script = pkg_dir.join("cli.js");
1531 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1532
1533 create_bin_shim(
1534 &bin_dir,
1535 "@scope/mycli",
1536 &script,
1537 BinShimOptions {
1538 extend_node_path: false,
1539 prefer_symlinked_executables: Some(false),
1540 hidden_modules_dir: None,
1541 },
1542 )
1543 .unwrap();
1544 assert!(bin_dir.join("@scope").exists());
1545
1546 remove_bin_shim(&bin_dir, "@scope/mycli");
1547 assert!(!bin_dir.join("@scope/mycli").exists());
1548 assert!(!bin_dir.join("@scope").exists());
1549 }
1550
1551 #[cfg(unix)]
1552 #[test]
1553 fn create_bin_shim_writes_posix_shim_when_symlink_opt_out() {
1554 let dir = tempfile::tempdir().unwrap();
1555 let bin_dir = dir.path().join("node_modules/.bin");
1556 std::fs::create_dir_all(&bin_dir).unwrap();
1557 let pkg_dir = dir.path().join("pkg");
1558 std::fs::create_dir_all(&pkg_dir).unwrap();
1559 let script = pkg_dir.join("cli.js");
1560 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1561
1562 create_bin_shim(
1563 &bin_dir,
1564 "mycli",
1565 &script,
1566 BinShimOptions {
1567 extend_node_path: false,
1568 prefer_symlinked_executables: Some(false),
1569 hidden_modules_dir: None,
1570 },
1571 )
1572 .unwrap();
1573
1574 let path = bin_dir.join("mycli");
1575 let meta = path.symlink_metadata().unwrap();
1577 assert!(!meta.file_type().is_symlink());
1578 let content = std::fs::read_to_string(&path).unwrap();
1579 assert!(content.starts_with("#!/bin/sh"));
1580 assert!(content.contains("exec \"$basedir/node\""));
1581 assert!(content.contains(POSIX_SHIM_MARKER_PREFIX));
1584 assert!(!content.contains("NODE_PATH"));
1586 use std::os::unix::fs::PermissionsExt;
1588 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
1589 assert_eq!(mode & 0o111, 0o111);
1590 }
1591
1592 #[cfg(unix)]
1593 #[test]
1594 fn posix_shim_executes_target_through_external_symlink_chain() {
1595 use std::os::unix::fs::symlink;
1596
1597 let dir = tempfile::tempdir().unwrap();
1598 let bin_dir = dir.path().join("node_modules/.bin");
1599 let pkg_dir = dir.path().join("node_modules/pkg/bin");
1600 std::fs::create_dir_all(&bin_dir).unwrap();
1601 std::fs::create_dir_all(&pkg_dir).unwrap();
1602 let target = pkg_dir.join("tool");
1603 std::fs::write(&target, "#!/bin/sh\necho shim-target\n").unwrap();
1604
1605 create_bin_shim(
1606 &bin_dir,
1607 "tool",
1608 &target,
1609 BinShimOptions {
1610 extend_node_path: false,
1611 prefer_symlinked_executables: Some(false),
1612 hidden_modules_dir: None,
1613 },
1614 )
1615 .unwrap();
1616
1617 let absolute_hop = dir.path().join("absolute-hop");
1618 symlink(bin_dir.join("tool"), &absolute_hop).unwrap();
1619 let path_dir = dir.path().join("local/bin");
1620 std::fs::create_dir_all(&path_dir).unwrap();
1621 let relative_hop = path_dir.join("tool");
1622 symlink("../../absolute-hop", &relative_hop).unwrap();
1623
1624 let output = std::process::Command::new(&relative_hop).output().unwrap();
1625 assert!(
1626 output.status.success(),
1627 "shim failed: {}",
1628 String::from_utf8_lossy(&output.stderr)
1629 );
1630 assert_eq!(String::from_utf8_lossy(&output.stdout), "shim-target\n");
1631 }
1632
1633 #[cfg(unix)]
1634 #[test]
1635 fn posix_shim_executes_non_script_target_replaced_after_linking() {
1636 let dir = tempfile::tempdir().unwrap();
1637 let bin_dir = dir.path().join("node_modules/.bin");
1638 std::fs::create_dir_all(&bin_dir).unwrap();
1639 let pkg_dir = dir.path().join("pkg");
1640 std::fs::create_dir_all(&pkg_dir).unwrap();
1641 let target = pkg_dir.join("native.exe");
1642 std::fs::write(&target, "postinstall has not run yet\n").unwrap();
1643
1644 create_bin_shim(
1645 &bin_dir,
1646 "native",
1647 &target,
1648 BinShimOptions {
1649 extend_node_path: true,
1650 prefer_symlinked_executables: Some(false),
1651 hidden_modules_dir: None,
1652 },
1653 )
1654 .unwrap();
1655
1656 let shim = bin_dir.join("native");
1657 let content = std::fs::read_to_string(&shim).unwrap();
1658 assert!(content.contains("exec \"$basedir/../../pkg/native.exe\" \"$@\""));
1659 assert!(!content.contains("exec node"));
1660
1661 std::fs::write(&target, "#!/bin/sh\nprintf 'native-%s\\n' \"$1\"\n").unwrap();
1662 use std::os::unix::fs::PermissionsExt;
1663 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
1664
1665 let output = std::process::Command::new(&shim)
1666 .arg("ok")
1667 .output()
1668 .unwrap();
1669 assert!(output.status.success());
1670 assert_eq!(output.stdout, b"native-ok\n");
1671 }
1672
1673 #[cfg(unix)]
1674 #[test]
1675 fn parse_posix_shim_target_round_trips_generator_output() {
1676 let dir = tempfile::tempdir().unwrap();
1680 let bin_dir = dir.path().join("node_modules/.bin");
1681 std::fs::create_dir_all(&bin_dir).unwrap();
1682 let pkg_dir = dir
1683 .path()
1684 .join("node_modules/.aube/semver@1.0.0/node_modules/semver");
1685 std::fs::create_dir_all(&pkg_dir).unwrap();
1686 let script = pkg_dir.join("bin/semver.js");
1687 std::fs::create_dir_all(script.parent().unwrap()).unwrap();
1688 std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
1689
1690 create_bin_shim(
1691 &bin_dir,
1692 "semver",
1693 &script,
1694 BinShimOptions {
1695 extend_node_path: true,
1696 prefer_symlinked_executables: Some(false),
1697 hidden_modules_dir: None,
1698 },
1699 )
1700 .unwrap();
1701
1702 let content = std::fs::read_to_string(bin_dir.join("semver")).unwrap();
1703 let rel = parse_posix_shim_target(&content).expect("shim should carry its marker");
1704 assert_eq!(
1705 rel,
1706 "../.aube/semver@1.0.0/node_modules/semver/bin/semver.js",
1707 );
1708 }
1709
1710 #[test]
1711 fn parse_posix_shim_target_rejects_foreign_scripts() {
1712 assert!(parse_posix_shim_target("#!/bin/sh\necho hi\n").is_none());
1716 assert!(
1719 parse_posix_shim_target("#!/bin/sh\nexec node \"$basedir/../pkg/cli.js\" \"$@\"\n",)
1720 .is_none()
1721 );
1722 }
1723
1724 #[test]
1725 fn resolve_bin_shim_rejects_oversized_and_foreign_files() {
1726 let dir = tempfile::tempdir().unwrap();
1727 let oversized = dir.path().join("oversized");
1728 std::fs::write(&oversized, vec![b'x'; MAX_BIN_SHIM_BYTES as usize + 1]).unwrap();
1729 assert_eq!(resolve_bin_shim(&oversized).unwrap(), None);
1730
1731 let foreign = dir.path().join("foreign.cmd");
1732 std::fs::write(
1733 &foreign,
1734 "@SETLOCAL\r\n\
1735 @IF EXIST \"%~dp0\\node.exe\" (\r\n\
1736 \x20 \"%~dp0\\node.exe\" \"%~dp0\\payload.exe\" %*\r\n\
1737 ) ELSE (\r\n\
1738 \x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\
1739 \x20 node \"%~dp0\\payload.exe\" %*\r\n\
1740 )\r\n\
1741 @ECHO foreign behavior\r\n",
1742 )
1743 .unwrap();
1744 assert_eq!(resolve_bin_shim(&foreign).unwrap(), None);
1745
1746 let malformed_env = dir.path().join("malformed-env");
1747 std::fs::write(
1748 &malformed_env,
1749 "#!/bin/sh\n\
1750 # aube-bin-shim v1 target=pkg/tool\n\
1751 export NODE_PATH=\"not-basedir-relative\"\n",
1752 )
1753 .unwrap();
1754 assert_eq!(resolve_bin_shim(&malformed_env).unwrap(), None);
1755
1756 let windows_env = dir.path().join("windows-env");
1757 std::fs::write(
1758 &windows_env,
1759 "#!/bin/sh\n\
1760 # aube-bin-shim v1 target=pkg/tool\n\
1761 export NODE_PATH=\"$basedir/..;$basedir/../.aube/node_modules\"\n",
1762 )
1763 .unwrap();
1764 assert_eq!(resolve_bin_shim(&windows_env).unwrap(), None);
1765 }
1766
1767 #[test]
1768 fn resolve_bin_shim_decodes_cmd_target_and_multi_entry_node_path() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let bin_dir = dir.path().join("node_modules/.bin");
1771 std::fs::create_dir_all(&bin_dir).unwrap();
1772 let shim = bin_dir.join("tool.cmd");
1773 std::fs::write(
1774 &shim,
1775 "@SETLOCAL\r\n\
1776 @SET NODE_PATH=%~dp0..;%~dp0..\\.aube\\node_modules\r\n\
1777 @IF EXIST \"%~dp0\\node.exe\" (\r\n\
1778 \x20 \"%~dp0\\node.exe\" \"%~dp0\\..\\pkg\\tool.exe\" %*\r\n\
1779 ) ELSE (\r\n\
1780 \x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\
1781 \x20 node \"%~dp0\\..\\pkg\\tool.exe\" %*\r\n\
1782 )\r\n",
1783 )
1784 .unwrap();
1785
1786 let resolved = resolve_bin_shim(&shim).unwrap().unwrap();
1787 assert_eq!(
1788 resolved.target,
1789 dir.path().join("node_modules/pkg/tool.exe")
1790 );
1791 assert_eq!(
1792 resolved.node_path,
1793 Some(
1794 std::env::join_paths([
1795 dir.path().join("node_modules"),
1796 dir.path()
1797 .join("node_modules")
1798 .join(".aube")
1799 .join("node_modules"),
1800 ])
1801 .unwrap()
1802 )
1803 );
1804 }
1805
1806 #[cfg(unix)]
1807 #[test]
1808 fn create_bin_shim_injects_node_path_in_posix_shim() {
1809 let dir = tempfile::tempdir().unwrap();
1810 let bin_dir = dir.path().join("node_modules/.bin");
1811 std::fs::create_dir_all(&bin_dir).unwrap();
1812 let pkg_dir = dir.path().join("pkg");
1813 std::fs::create_dir_all(&pkg_dir).unwrap();
1814 let script = pkg_dir.join("cli.js");
1815 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1816
1817 create_bin_shim(
1818 &bin_dir,
1819 "mycli",
1820 &script,
1821 BinShimOptions {
1822 extend_node_path: true,
1823 prefer_symlinked_executables: Some(false),
1824 hidden_modules_dir: None,
1825 },
1826 )
1827 .unwrap();
1828
1829 let content = std::fs::read_to_string(bin_dir.join("mycli")).unwrap();
1830 assert!(content.contains("export NODE_PATH=\"$basedir/..\""));
1831 }
1832
1833 #[cfg(unix)]
1834 #[test]
1835 fn create_bin_shim_appends_hidden_modules_to_node_path() {
1836 let dir = tempfile::tempdir().unwrap();
1843 let bin_dir = dir.path().join("node_modules/.bin");
1844 std::fs::create_dir_all(&bin_dir).unwrap();
1845 let hidden = dir.path().join("node_modules/.aube/node_modules");
1846 std::fs::create_dir_all(&hidden).unwrap();
1847 let pkg_dir = dir.path().join("pkg");
1848 std::fs::create_dir_all(&pkg_dir).unwrap();
1849 let script = pkg_dir.join("cli.js");
1850 std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
1851
1852 create_bin_shim(
1853 &bin_dir,
1854 "mycli",
1855 &script,
1856 BinShimOptions {
1857 extend_node_path: true,
1858 prefer_symlinked_executables: Some(false),
1859 hidden_modules_dir: Some(hidden.as_path()),
1860 },
1861 )
1862 .unwrap();
1863
1864 let content = std::fs::read_to_string(bin_dir.join("mycli")).unwrap();
1865 assert!(
1866 content.contains("export NODE_PATH=\"$basedir/..:$basedir/../.aube/node_modules\""),
1867 "expected two-entry NODE_PATH, got:\n{content}"
1868 );
1869 let resolved = resolve_bin_shim(&bin_dir.join("mycli")).unwrap().unwrap();
1870 assert_eq!(resolved.target, script);
1871 assert_eq!(
1872 resolved.node_path,
1873 Some(std::env::join_paths([dir.path().join("node_modules"), hidden]).unwrap())
1874 );
1875 }
1876
1877 #[cfg(unix)]
1878 #[test]
1879 fn create_bin_shim_ignores_node_path_for_symlink() {
1880 let dir = tempfile::tempdir().unwrap();
1885 let bin_dir = dir.path().join("node_modules/.bin");
1886 std::fs::create_dir_all(&bin_dir).unwrap();
1887 let pkg_dir = dir.path().join("pkg");
1888 std::fs::create_dir_all(&pkg_dir).unwrap();
1889 let script = pkg_dir.join("cli.js");
1890 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1891
1892 create_bin_shim(
1893 &bin_dir,
1894 "mycli",
1895 &script,
1896 BinShimOptions {
1897 extend_node_path: true,
1898 prefer_symlinked_executables: None,
1899 hidden_modules_dir: None,
1900 },
1901 )
1902 .unwrap();
1903
1904 let link = bin_dir.join("mycli");
1905 assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
1906 }
1907
1908 #[cfg(windows)]
1909 #[test]
1910 fn create_bin_shim_injects_node_path_on_windows() {
1911 let dir = tempfile::tempdir().unwrap();
1912 let bin_dir = dir.path().join("node_modules/.bin");
1913 std::fs::create_dir_all(&bin_dir).unwrap();
1914 let pkg_dir = dir.path().join("pkg");
1915 std::fs::create_dir_all(&pkg_dir).unwrap();
1916 let script = pkg_dir.join("cli.js");
1917 std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
1918
1919 create_bin_shim(
1920 &bin_dir,
1921 "mycli",
1922 &script,
1923 BinShimOptions {
1924 extend_node_path: true,
1925 prefer_symlinked_executables: None,
1926 hidden_modules_dir: None,
1927 },
1928 )
1929 .unwrap();
1930
1931 let cmd = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
1932 assert!(cmd.contains("@SET NODE_PATH=%~dp0.."));
1933 let ps1 = std::fs::read_to_string(bin_dir.join("mycli.ps1")).unwrap();
1934 assert!(ps1.contains("$env:NODE_PATH=\"$basedir/..\""));
1935 let sh = std::fs::read_to_string(bin_dir.join("mycli")).unwrap();
1936 assert!(sh.contains("export NODE_PATH=\"$basedir/..\""));
1937 }
1938
1939 #[cfg(windows)]
1940 #[test]
1941 fn create_bin_shim_appends_hidden_modules_on_windows_uses_semicolon() {
1942 let dir = tempfile::tempdir().unwrap();
1948 let bin_dir = dir.path().join("node_modules/.bin");
1949 std::fs::create_dir_all(&bin_dir).unwrap();
1950 let hidden = dir.path().join("node_modules/.aube/node_modules");
1951 std::fs::create_dir_all(&hidden).unwrap();
1952 let pkg_dir = dir.path().join("pkg");
1953 std::fs::create_dir_all(&pkg_dir).unwrap();
1954 let script = pkg_dir.join("cli.js");
1955 std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
1956
1957 create_bin_shim(
1958 &bin_dir,
1959 "mycli",
1960 &script,
1961 BinShimOptions {
1962 extend_node_path: true,
1963 prefer_symlinked_executables: None,
1964 hidden_modules_dir: Some(hidden.as_path()),
1965 },
1966 )
1967 .unwrap();
1968
1969 let cmd = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
1970 assert!(
1971 cmd.contains("@SET NODE_PATH=%~dp0..;%~dp0..\\.aube\\node_modules"),
1972 "cmd shim should join with `;` and use backslashes:\n{cmd}"
1973 );
1974 let ps1 = std::fs::read_to_string(bin_dir.join("mycli.ps1")).unwrap();
1975 assert!(
1976 ps1.contains("$env:NODE_PATH=\"$basedir/..;$basedir/../.aube/node_modules\""),
1977 "ps1 shim should join with `;` even though paths use `/`:\n{ps1}"
1978 );
1979 let sh = std::fs::read_to_string(bin_dir.join("mycli")).unwrap();
1980 assert!(
1981 sh.contains("export NODE_PATH=\"$basedir/..;$basedir/../.aube/node_modules\""),
1982 "windows .sh shim must use `;` so Node parses both entries:\n{sh}"
1983 );
1984 }
1985
1986 #[cfg(windows)]
1987 #[test]
1988 fn create_bin_shim_omits_node_path_when_false() {
1989 let dir = tempfile::tempdir().unwrap();
1990 let bin_dir = dir.path().join("node_modules/.bin");
1991 std::fs::create_dir_all(&bin_dir).unwrap();
1992 let pkg_dir = dir.path().join("pkg");
1993 std::fs::create_dir_all(&pkg_dir).unwrap();
1994 let script = pkg_dir.join("cli.js");
1995 std::fs::write(&script, "console.log('hi');\n").unwrap();
1996
1997 create_bin_shim(
1998 &bin_dir,
1999 "mycli",
2000 &script,
2001 BinShimOptions {
2002 extend_node_path: false,
2003 prefer_symlinked_executables: None,
2004 hidden_modules_dir: None,
2005 },
2006 )
2007 .unwrap();
2008
2009 let cmd = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
2010 assert!(!cmd.contains("NODE_PATH"));
2011 }
2012
2013 #[test]
2026 fn is_safe_prog_accepts_real_world_interpreters() {
2027 assert!(is_safe_prog("node"));
2028 assert!(is_safe_prog("bash"));
2029 assert!(is_safe_prog("sh"));
2030 assert!(is_safe_prog("python3"));
2031 assert!(is_safe_prog("python3.11"));
2032 assert!(is_safe_prog("ruby"));
2033 assert!(is_safe_prog("deno"));
2034 assert!(is_safe_prog("bun"));
2035 assert!(is_safe_prog("node18"));
2036 assert!(is_safe_prog("node-18"));
2037 assert!(is_safe_prog("pwsh"));
2038 assert!(is_safe_prog("c++"));
2039 assert!(is_safe_prog("ocaml-ng"));
2040 assert!(is_safe_prog("tsx_dev"));
2041 }
2042
2043 #[test]
2044 fn is_safe_prog_rejects_cmd_metachars() {
2045 assert!(!is_safe_prog("node\"&calc&\""));
2046 assert!(!is_safe_prog("node&calc"));
2047 assert!(!is_safe_prog("node|evil"));
2048 assert!(!is_safe_prog("node>out"));
2049 assert!(!is_safe_prog("node<in"));
2050 assert!(!is_safe_prog("node^x"));
2051 assert!(!is_safe_prog("node%PATH%"));
2052 assert!(!is_safe_prog("a b"));
2053 assert!(!is_safe_prog("node;rm"));
2054 assert!(!is_safe_prog("node`evil`"));
2055 assert!(!is_safe_prog("node$(evil)"));
2056 assert!(!is_safe_prog("node\\evil"));
2057 assert!(!is_safe_prog("node/evil"));
2058 assert!(!is_safe_prog("node'evil'"));
2059 }
2060
2061 #[test]
2062 fn is_safe_prog_rejects_non_ascii() {
2063 assert!(!is_safe_prog("node"));
2068 assert!(!is_safe_prog("node\u{00a0}"));
2069 assert!(!is_safe_prog("nöde"));
2070 }
2071
2072 #[test]
2073 fn is_safe_prog_rejects_control_chars() {
2074 assert!(!is_safe_prog("node\0"));
2075 assert!(!is_safe_prog("node\n"));
2076 assert!(!is_safe_prog("node\r"));
2077 assert!(!is_safe_prog("node\t"));
2078 }
2079
2080 #[test]
2081 fn is_safe_prog_rejects_empty_and_oversize() {
2082 assert!(!is_safe_prog(""));
2083 let oversize = "a".repeat(65);
2084 assert!(!is_safe_prog(&oversize));
2085 let at_limit = "a".repeat(64);
2086 assert!(is_safe_prog(&at_limit));
2087 }
2088
2089 #[test]
2090 fn is_safe_prog_rejects_non_alphanumeric_leading_char() {
2091 assert!(!is_safe_prog("-node"));
2096 assert!(!is_safe_prog(".node"));
2097 assert!(!is_safe_prog("_node"));
2098 assert!(!is_safe_prog("+node"));
2099 assert!(is_safe_prog("python3.11"));
2101 assert!(is_safe_prog("node-18"));
2102 assert!(is_safe_prog("tsx_dev"));
2103 assert!(is_safe_prog("c++"));
2104 }
2105
2106 #[test]
2107 fn detect_interpreter_absolute_path_with_cmd_injection_falls_back() {
2108 let dir = tempfile::tempdir().unwrap();
2112 let script = dir.path().join("cli.js");
2113 std::fs::write(&script, b"#!/usr/bin/node\"&calc&\"\nbody\n").unwrap();
2114 assert_eq!(
2115 detect_bin_launch(&script),
2116 BinLaunch::Interpreter("node".to_string())
2117 );
2118 }
2119
2120 #[test]
2121 fn detect_interpreter_env_style_with_cmd_injection_falls_back() {
2122 let dir = tempfile::tempdir().unwrap();
2123 let script = dir.path().join("cli.js");
2124 std::fs::write(&script, b"#!/usr/bin/env \"node&calc&\"\nbody\n").unwrap();
2125 assert_eq!(
2126 detect_bin_launch(&script),
2127 BinLaunch::Interpreter("node".to_string())
2128 );
2129 }
2130
2131 #[test]
2132 fn detect_interpreter_env_flags_with_cmd_injection_falls_back() {
2133 let dir = tempfile::tempdir().unwrap();
2134 let script = dir.path().join("cli.js");
2135 std::fs::write(&script, b"#!/usr/bin/env \"x&calc.exe&\"\nbody\n").unwrap();
2136 assert_eq!(
2137 detect_bin_launch(&script),
2138 BinLaunch::Interpreter("node".to_string())
2139 );
2140 }
2141
2142 #[test]
2143 fn detect_interpreter_fallback_uses_extension() {
2144 let dir = tempfile::tempdir().unwrap();
2148 let script = dir.path().join("cli.sh");
2149 std::fs::write(&script, b"#!/usr/bin/env \"bash&evil&\"\nbody\n").unwrap();
2150 assert_eq!(
2151 detect_bin_launch(&script),
2152 BinLaunch::Interpreter("sh".to_string())
2153 );
2154 }
2155
2156 #[test]
2157 fn detect_interpreter_valid_dotted_version_passes() {
2158 let dir = tempfile::tempdir().unwrap();
2160 let script = dir.path().join("cli.py");
2161 std::fs::write(&script, b"#!/usr/bin/env python3.11\n").unwrap();
2162 assert_eq!(
2163 detect_bin_launch(&script),
2164 BinLaunch::Interpreter("python3.11".to_string())
2165 );
2166 }
2167
2168 #[test]
2169 fn detect_interpreter_long_prog_rejected_falls_back() {
2170 let dir = tempfile::tempdir().unwrap();
2173 let script = dir.path().join("cli.js");
2174 let long = "a".repeat(128);
2175 let shebang = format!("#!/usr/bin/env {long}\nbody\n");
2176 std::fs::write(&script, shebang.as_bytes()).unwrap();
2177 assert_eq!(
2178 detect_bin_launch(&script),
2179 BinLaunch::Interpreter("node".to_string())
2180 );
2181 }
2182
2183 #[test]
2192 fn safe_prog_passes_through_valid() {
2193 assert_eq!(safe_prog("node"), "node");
2194 assert_eq!(safe_prog("python3.11"), "python3.11");
2195 }
2196
2197 #[test]
2198 fn safe_prog_substitutes_on_unsafe() {
2199 assert_eq!(safe_prog("node\"&calc&\""), "node");
2202 assert_eq!(safe_prog(""), "node");
2203 assert_eq!(safe_prog("a b"), "node");
2204 assert_eq!(safe_prog("node\0"), "node");
2205 }
2206
2207 #[cfg(windows)]
2208 #[test]
2209 fn generate_cmd_shim_never_splices_unsafe_prog() {
2210 let shim = generate_cmd_shim(
2213 &BinLaunch::Interpreter("node\"&calc&\"".to_string()),
2214 "..\\pkg\\entry.js",
2215 None,
2216 );
2217 assert!(
2218 !shim.contains("&calc&"),
2219 "unsafe prog spliced into cmd shim:\n{shim}"
2220 );
2221 assert!(
2222 !shim.contains("\"&"),
2223 "stray quote-ampersand in cmd shim:\n{shim}"
2224 );
2225 assert!(shim.contains("node.exe"));
2227 }
2228
2229 #[cfg(windows)]
2230 #[test]
2231 fn windows_direct_shims_execute_the_target_without_node() {
2232 let cmd = generate_cmd_shim(&BinLaunch::Direct, "..\\pkg\\native.exe", None);
2233 assert!(cmd.contains("@\"%~dp0\\..\\pkg\\native.exe\" %*"));
2234 assert!(!cmd.contains("node"));
2235
2236 let ps1 = generate_ps1_shim(&BinLaunch::Direct, "../pkg/native.exe", None);
2237 assert!(ps1.contains("& \"$basedir/../pkg/native.exe\" $args"));
2238 assert!(!ps1.contains("node"));
2239
2240 let sh = generate_sh_shim(&BinLaunch::Direct, "../pkg/native.exe", None);
2241 assert!(sh.contains("exec \"$basedir/../pkg/native.exe\" \"$@\""));
2242 assert!(!sh.contains("node"));
2243 }
2244
2245 #[cfg(windows)]
2246 #[test]
2247 fn generate_ps1_shim_never_splices_unsafe_prog() {
2248 let shim = generate_ps1_shim(
2249 &BinLaunch::Interpreter("bash&rm".to_string()),
2250 "../pkg/entry.js",
2251 None,
2252 );
2253 assert!(
2254 !shim.contains("&rm"),
2255 "unsafe prog spliced into ps1 shim:\n{shim}"
2256 );
2257 }
2258
2259 #[cfg(windows)]
2260 #[test]
2261 fn generate_sh_shim_never_splices_unsafe_prog() {
2262 let shim = generate_sh_shim(
2263 &BinLaunch::Interpreter("sh;rm".to_string()),
2264 "../pkg/entry.js",
2265 None,
2266 );
2267 assert!(
2268 !shim.contains(";rm"),
2269 "unsafe prog spliced into sh shim:\n{shim}"
2270 );
2271 }
2272
2273 #[cfg(unix)]
2274 #[test]
2275 fn generate_posix_shim_never_splices_unsafe_prog() {
2276 let shim = generate_posix_shim(
2277 &BinLaunch::Interpreter("sh;rm".to_string()),
2278 "../pkg/entry.js",
2279 None,
2280 );
2281 assert!(
2282 !shim.contains(";rm"),
2283 "unsafe prog spliced into posix shim:\n{shim}"
2284 );
2285 }
2286}