use std::io;
use std::path::{Component, Path, PathBuf};
pub fn create_dir_link(target: &Path, link: &Path) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
let abs_target = if target.is_absolute() {
target.to_path_buf()
} else {
let parent = link.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"junction link has no parent directory",
)
})?;
normalize_path(&parent.join(target))
};
create_junction_with_retry(&abs_target, link)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (target, link);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"directory links are not supported on this platform",
))
}
}
#[cfg(windows)]
fn create_junction_with_retry(target: &Path, link: &Path) -> io::Result<()> {
let mut attempt = 0;
let mut delay_ms = 50u64;
loop {
match junction::create(target, link) {
Ok(()) => return Ok(()),
Err(e) if is_retriable_link_error(&e) && attempt < 9 => {
std::thread::sleep(std::time::Duration::from_millis(delay_ms));
delay_ms = (delay_ms * 2).min(2000);
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
#[cfg(windows)]
fn is_retriable_link_error(error: &io::Error) -> bool {
matches!(error.raw_os_error(), Some(5 | 32))
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BinShimOptions {
pub extend_node_path: bool,
pub prefer_symlinked_executables: Option<bool>,
}
pub fn create_bin_shim(
bin_dir: &Path,
name: &str,
target: &Path,
opts: BinShimOptions,
) -> io::Result<()> {
validate_bin_name(name)?;
#[cfg(unix)]
{
let write_shim = matches!(opts.prefer_symlinked_executables, Some(false));
let link_path = bin_dir.join(name);
let link_parent = link_path.parent().unwrap_or(bin_dir);
std::fs::create_dir_all(link_parent)?;
let _ = std::fs::remove_file(&link_path);
if write_shim {
let rel = relative_bin_target(link_parent, target);
let node_path_rel = relative_bin_target(link_parent, node_modules_dir_for_bin(bin_dir));
let prog = detect_interpreter(target);
std::fs::write(
&link_path,
generate_posix_shim(
&prog,
&rel,
opts.extend_node_path.then_some(node_path_rel.as_str()),
),
)?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&link_path, std::fs::Permissions::from_mode(0o755))?;
} else {
std::os::unix::fs::symlink(target, &link_path)?;
use std::os::unix::fs::PermissionsExt;
if target.exists() {
let _ = std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o755));
}
}
}
#[cfg(windows)]
{
let link_path = bin_dir.join(name);
let link_parent = link_path.parent().unwrap_or(bin_dir);
for p in win_shim_paths(bin_dir, name) {
if std::fs::remove_file(&p).is_err() {
let _ = std::fs::remove_dir(&p);
}
}
if let Err(e) = std::fs::create_dir_all(link_parent)
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
return Err(e);
}
let rel = relative_bin_target(link_parent, target);
let node_path_rel = relative_bin_target(link_parent, node_modules_dir_for_bin(bin_dir));
let prog = detect_interpreter(target);
let rel_backslash = rel.replace('/', "\\");
let rel_fwdslash = rel.replace('\\', "/");
let node_path_backslash = node_path_rel.replace('/', "\\");
let node_path_fwdslash = node_path_rel.replace('\\', "/");
let node_path_backslash = opts
.extend_node_path
.then_some(node_path_backslash.as_str());
let node_path_fwdslash = opts.extend_node_path.then_some(node_path_fwdslash.as_str());
write_shim_file(
&bin_dir.join(format!("{name}.cmd")),
generate_cmd_shim(&prog, &rel_backslash, node_path_backslash).as_bytes(),
)?;
write_shim_file(
&bin_dir.join(format!("{name}.ps1")),
generate_ps1_shim(&prog, &rel_fwdslash, node_path_fwdslash).as_bytes(),
)?;
write_shim_file(
&bin_dir.join(name),
generate_sh_shim(&prog, &rel_fwdslash, node_path_fwdslash).as_bytes(),
)?;
}
#[cfg(not(any(unix, windows)))]
{
let _ = (bin_dir, name, target, opts);
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"bin shims are not supported on this platform",
));
}
Ok(())
}
pub fn validate_bin_name(name: &str) -> io::Result<()> {
if name.is_empty() || name.len() > 255 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid bin name: {name:?}"),
));
}
let parts: Vec<&str> = name.split('/').collect();
let ok = match parts.as_slice() {
[bare] => is_safe_bin_component(bare),
[scope, bare] => {
scope.starts_with('@')
&& scope.len() > 1
&& is_safe_bin_component(scope)
&& is_safe_bin_component(bare)
}
_ => false,
};
if !ok {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid bin name: {name:?}"),
));
}
Ok(())
}
pub fn validate_bin_target(rel: &str) -> io::Result<()> {
if rel.is_empty() || rel.contains('\0') || rel.contains('\\') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid bin target: {rel:?}"),
));
}
for ch in rel.chars() {
if matches!(
ch,
'$' | '`'
| '%'
| '"'
| '\''
| '&'
| '|'
| '^'
| ';'
| '<'
| '>'
| '('
| ')'
| '!'
| '*'
| '?'
) || ch.is_control()
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("bin target contains shell metacharacter: {rel:?}"),
));
}
}
let path = Path::new(rel);
if path.is_absolute()
|| path.has_root()
|| rel.starts_with('/')
|| rel.len() >= 2 && rel.as_bytes()[1] == b':'
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("absolute bin target: {rel:?}"),
));
}
for comp in path.components() {
match comp {
Component::Normal(_) | Component::CurDir => {}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("bin target escapes package: {rel:?}"),
));
}
}
}
Ok(())
}
fn is_safe_bin_component(s: &str) -> bool {
if s.is_empty() || s == "." || s == ".." {
return false;
}
if s.bytes()
.any(|b| b == 0 || b == b'/' || b == b'\\' || b.is_ascii_control())
{
return false;
}
#[cfg(windows)]
{
if s.contains(':') || is_windows_reserved(s) || s.ends_with('.') || s.ends_with(' ') {
return false;
}
}
true
}
#[cfg(windows)]
fn is_windows_reserved(s: &str) -> bool {
let stem = match s.find('.') {
Some(i) => &s[..i],
None => s,
};
let upper = stem.to_ascii_uppercase();
match upper.as_str() {
"CON" | "PRN" | "NUL" | "AUX" => true,
s if s.len() == 4
&& (s.starts_with("COM") || s.starts_with("LPT"))
&& s.as_bytes()[3].is_ascii_digit()
&& s.as_bytes()[3] != b'0' =>
{
true
}
_ => false,
}
}
pub fn remove_bin_shim(bin_dir: &Path, name: &str) {
if validate_bin_name(name).is_err() {
return;
}
let link_path = bin_dir.join(name);
let _ = std::fs::remove_file(&link_path);
#[cfg(windows)]
for p in win_shim_paths(bin_dir, name).into_iter().skip(1) {
let _ = std::fs::remove_file(&p);
}
if let Some(parent) = link_path.parent()
&& parent != bin_dir
{
let _ = std::fs::remove_dir(parent);
}
}
#[cfg(windows)]
fn write_shim_file(dst: &Path, contents: &[u8]) -> io::Result<()> {
match std::fs::write(dst, contents) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists || e.raw_os_error() == Some(183) => {
let _ = std::fs::remove_file(dst);
let _ = std::fs::remove_dir(dst);
std::fs::write(dst, contents)
}
Err(e) => Err(e),
}
}
#[cfg(windows)]
fn win_shim_paths(bin_dir: &Path, name: &str) -> [PathBuf; 3] {
[
bin_dir.join(name),
bin_dir.join(format!("{name}.cmd")),
bin_dir.join(format!("{name}.ps1")),
]
}
fn relative_bin_target(base_dir: &Path, target: &Path) -> String {
let base = aube_util::path::strip_verbatim_prefix(base_dir);
let target = aube_util::path::strip_verbatim_prefix(target);
pathdiff::diff_paths(&target, &base)
.unwrap_or(target)
.to_string_lossy()
.replace('\\', "/")
}
fn node_modules_dir_for_bin(bin_dir: &Path) -> &Path {
bin_dir.parent().unwrap_or(bin_dir)
}
fn detect_interpreter(target: &Path) -> String {
use std::io::Read;
let mut buf = [0u8; 256];
let n = std::fs::File::open(target)
.and_then(|mut f| f.read(&mut buf))
.unwrap_or(0);
let content = &buf[..n];
if n > 2
&& content.starts_with(b"#!")
&& let Some(line_end) = content.iter().position(|&b| b == b'\n')
{
let line = String::from_utf8_lossy(&content[2..line_end]);
let line = line.trim();
let prog = if let Some(rest) = line.strip_prefix("/usr/bin/env") {
let rest = rest.trim_start();
let rest = rest.strip_prefix("-S").map_or(rest, |r| r.trim_start());
rest.split_whitespace()
.find(|s| !s.contains('='))
.unwrap_or("node")
} else {
line.split_whitespace()
.next()
.and_then(|p| p.rsplit('/').next())
.unwrap_or("node")
};
if is_safe_prog(prog) {
return prog.to_string();
}
tracing::warn!("ignoring unsafe shebang interpreter in {target:?}: {prog:?}");
}
default_interpreter_for_extension(target)
}
fn is_safe_prog(prog: &str) -> bool {
if prog.is_empty() || prog.len() > 64 {
return false;
}
let mut chars = prog.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn default_interpreter_for_extension(target: &Path) -> String {
match target.extension().and_then(|e| e.to_str()) {
Some("js" | "cjs" | "mjs") | None => "node".to_string(),
Some("cmd" | "bat") => "cmd".to_string(),
Some("ps1") => "pwsh".to_string(),
Some("sh") => "sh".to_string(),
Some(_) => "node".to_string(),
}
}
fn safe_prog(prog: &str) -> &str {
if is_safe_prog(prog) {
prog
} else {
tracing::error!(
code = aube_codes::errors::ERR_AUBE_UNSAFE_SHEBANG_INTERPRETER,
"refusing to splice unsafe prog {prog:?} into shim, substituting \"node\""
);
"node"
}
}
#[cfg(windows)]
fn generate_cmd_shim(
prog: &str,
rel_target_backslash: &str,
node_path_rel_backslash: Option<&str>,
) -> String {
let prog = safe_prog(prog);
let node_path = node_path_rel_backslash.map_or(String::new(), |rel| {
format!("@SET NODE_PATH=%~dp0{rel}\r\n")
});
format!(
"@SETLOCAL\r\n\
{node_path}\
@IF EXIST \"%~dp0\\{prog}.exe\" (\r\n\
\x20 \"%~dp0\\{prog}.exe\" \"%~dp0\\{rel_target_backslash}\" %*\r\n\
) ELSE (\r\n\
\x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\
\x20 {prog} \"%~dp0\\{rel_target_backslash}\" %*\r\n\
)\r\n"
)
}
#[cfg(windows)]
fn generate_ps1_shim(
prog: &str,
rel_target_fwdslash: &str,
node_path_rel_fwdslash: Option<&str>,
) -> String {
let prog = safe_prog(prog);
let node_path = node_path_rel_fwdslash.map_or(String::new(), |rel| {
format!("$env:NODE_PATH=\"$basedir/{rel}\"\n")
});
format!(
"#!/usr/bin/env pwsh\n\
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent\n\
\n\
{node_path}\
$exe=\"\"\n\
if ($PSVersionTable.PSVersion -lt \"6.0\" -or $IsWindows) {{\n\
\x20 $exe=\".exe\"\n\
}}\n\
$ret=0\n\
if (Test-Path \"$basedir/{prog}$exe\") {{\n\
\x20 if ($MyInvocation.ExpectingInput) {{\n\
\x20\x20\x20 $input | & \"$basedir/{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
\x20 }} else {{\n\
\x20\x20\x20 & \"$basedir/{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
\x20 }}\n\
\x20 $ret=$LASTEXITCODE\n\
}} else {{\n\
\x20 if ($MyInvocation.ExpectingInput) {{\n\
\x20\x20\x20 $input | & \"{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
\x20 }} else {{\n\
\x20\x20\x20 & \"{prog}$exe\" \"$basedir/{rel_target_fwdslash}\" $args\n\
\x20 }}\n\
\x20 $ret=$LASTEXITCODE\n\
}}\n\
exit $ret\n"
)
}
#[cfg(windows)]
fn generate_sh_shim(
prog: &str,
rel_target_fwdslash: &str,
node_path_rel_fwdslash: Option<&str>,
) -> String {
let prog = safe_prog(prog);
let node_path = node_path_rel_fwdslash.map_or(String::new(), |rel| {
format!("export NODE_PATH=\"$basedir/{rel}\"\n")
});
format!(
"#!/bin/sh\n\
basedir=$(dirname \"$(echo \"$0\" | sed -e 's,\\\\,/,g')\")\n\
\n\
case `uname` in\n\
\x20\x20\x20 *CYGWIN*|*MINGW*|*MSYS*)\n\
\x20\x20\x20\x20\x20\x20\x20 if command -v cygpath > /dev/null 2>&1; then\n\
\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20 basedir=`cygpath -w \"$basedir\"`\n\
\x20\x20\x20\x20\x20\x20\x20 fi\n\
\x20\x20\x20 ;;\n\
esac\n\
\n\
{node_path}\
if [ -x \"$basedir/{prog}\" ]; then\n\
\x20 exec \"$basedir/{prog}\" \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
else\n\
\x20 exec {prog} \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
fi\n"
)
}
pub const POSIX_SHIM_MARKER_PREFIX: &str = "# aube-bin-shim v1 target=";
#[cfg(unix)]
fn generate_posix_shim(
prog: &str,
rel_target_fwdslash: &str,
node_path_rel_fwdslash: Option<&str>,
) -> String {
let prog = safe_prog(prog);
let node_path = node_path_rel_fwdslash.map_or(String::new(), |rel| {
format!("export NODE_PATH=\"$basedir/{rel}\"\n")
});
format!(
"#!/bin/sh\n\
{POSIX_SHIM_MARKER_PREFIX}{rel_target_fwdslash}\n\
basedir=$(dirname \"$0\")\n\
{node_path}\
if [ -x \"$basedir/{prog}\" ]; then\n\
\x20 exec \"$basedir/{prog}\" \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
else\n\
\x20 exec {prog} \"$basedir/{rel_target_fwdslash}\" \"$@\"\n\
fi\n"
)
}
pub fn parse_posix_shim_target(content: &str) -> Option<&str> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix(POSIX_SHIM_MARKER_PREFIX) {
return Some(rest);
}
}
None
}
pub fn normalize_path(path: &Path) -> PathBuf {
let mut out: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::ParentDir => {
if !matches!(
out.last(),
None | Some(Component::RootDir) | Some(Component::Prefix(_))
) {
out.pop();
} else {
out.push(comp);
}
}
Component::CurDir => {}
other => out.push(other),
}
}
out.iter().map(|c| c.as_os_str()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_bin_name_accepts_bare_and_scope() {
assert!(validate_bin_name("foo").is_ok());
assert!(validate_bin_name("foo-bar.js").is_ok());
assert!(validate_bin_name("@scope/foo").is_ok());
}
#[test]
fn validate_bin_name_rejects_traversal_and_separators() {
for bad in [
"",
"..",
".",
"../../../etc/passwd",
"a/b/c",
"a\\b",
"foo\0",
"/etc/cron.d/evil",
"\\\\server\\share\\x",
"C:\\x",
"@scope/../x",
"@/foo",
"scope/foo",
] {
assert!(validate_bin_name(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn validate_bin_target_rejects_shell_metacharacters() {
for bad in [
"bin/$(calc).js",
"bin/$env:USERPROFILE.js",
"bin/`id`.js",
"bin/%PATH%.js",
"bin/foo&bar.js",
"bin/foo|bar.js",
"bin/foo;bar.js",
"bin/foo>bar.js",
"bin/foo<bar.js",
"bin/foo\"bar.js",
"bin/foo'bar.js",
"bin/foo!bar.js",
] {
assert!(
validate_bin_target(bad).is_err(),
"must reject shell metachar payload {bad:?}"
);
}
}
#[test]
fn validate_bin_target_rejects_absolute_and_traversal() {
assert!(validate_bin_target("bin/cli.js").is_ok());
assert!(validate_bin_target("./cli.js").is_ok());
for bad in [
"",
"/etc/passwd",
"../../../etc/passwd",
"bin/../../../etc/passwd",
"C:/Windows/x",
"bin\\cli.js",
"cli\0.js",
] {
assert!(validate_bin_target(bad).is_err(), "should reject {bad:?}");
}
}
#[test]
fn create_bin_shim_rejects_traversing_name() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join(".bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let target = dir.path().join("cli.js");
std::fs::write(&target, "#!/usr/bin/env node\n").unwrap();
let err = create_bin_shim(
&bin_dir,
"../../../evil",
&target,
BinShimOptions::default(),
)
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn detect_interpreter_shebang_env_node() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_shebang_env_with_s_flag() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(
&script,
"#!/usr/bin/env -S node --harmony\nconsole.log('hi');\n",
)
.unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_shebang_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(&script, "#!/usr/bin/node\nconsole.log('hi');\n").unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_shebang_env_python() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.py");
std::fs::write(&script, "#!/usr/bin/env python3\nprint('hi')\n").unwrap();
assert_eq!(detect_interpreter(&script), "python3");
}
#[test]
fn detect_interpreter_shebang_with_env_vars() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(
&script,
"#!/usr/bin/env NODE_OPTIONS=--max-old-space-size=4096 node\nconsole.log('hi');\n",
)
.unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_no_shebang_js() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(&script, "console.log('hi');\n").unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_nonexistent_file_defaults_to_node() {
assert_eq!(
detect_interpreter(Path::new("/nonexistent/file.js")),
"node"
);
}
#[test]
fn relative_bin_target_computes_path() {
let bin_dir = Path::new("/project/node_modules/.bin");
let target =
Path::new("/project/node_modules/.aube/is-odd@3.0.1/node_modules/is-odd/cli.js");
let rel = relative_bin_target(bin_dir, target);
assert_eq!(rel, "../.aube/is-odd@3.0.1/node_modules/is-odd/cli.js");
}
#[cfg(windows)]
#[test]
fn relative_bin_target_strips_verbatim_prefix_from_target() {
let base = Path::new(r"C:\pkg\bin");
let target = Path::new(r"\\?\C:\pkg\global-aube\abc\node_modules\p\bin\p.cjs");
let rel = relative_bin_target(base, target);
assert_eq!(rel, "../global-aube/abc/node_modules/p/bin/p.cjs");
}
#[cfg(windows)]
#[test]
fn relative_bin_target_strips_verbatim_prefix_from_base() {
let base = Path::new(r"\\?\C:\pkg\bin");
let target = Path::new(r"C:\pkg\global-aube\abc\node_modules\p\bin\p.cjs");
let rel = relative_bin_target(base, target);
assert_eq!(rel, "../global-aube/abc/node_modules/p/bin/p.cjs");
}
#[cfg(windows)]
#[test]
fn relative_bin_target_preserves_unc_share_prefix() {
let base = Path::new(r"\\?\UNC\server\share\pkg\bin");
let target = Path::new(r"\\?\UNC\server\share\pkg\lib\cli.js");
let rel = relative_bin_target(base, target);
assert_eq!(rel, "../lib/cli.js");
}
#[cfg(windows)]
#[test]
fn normalize_collapses_parent_and_cur_dir() {
let p = Path::new(r"C:\a\b\.\..\c\d\..\e");
assert_eq!(normalize_path(p), PathBuf::from(r"C:\a\c\e"));
}
#[cfg(windows)]
#[test]
fn creates_junction_without_developer_mode() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target");
std::fs::create_dir(&target).unwrap();
std::fs::write(target.join("marker.txt"), b"hi").unwrap();
let link = dir.path().join("parent").join("link");
std::fs::create_dir_all(link.parent().unwrap()).unwrap();
let rel = Path::new("..").join("target");
create_dir_link(&rel, &link).unwrap();
assert_eq!(std::fs::read(link.join("marker.txt")).unwrap(), b"hi");
}
#[cfg(windows)]
#[test]
fn create_bin_shim_writes_three_files() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir
.path()
.join("node_modules/.aube/is-odd@3.0.1/node_modules/is-odd");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(&bin_dir, "is-odd", &script, BinShimOptions::default()).unwrap();
assert!(bin_dir.join("is-odd.cmd").exists());
assert!(bin_dir.join("is-odd.ps1").exists());
assert!(bin_dir.join("is-odd").exists());
let cmd = std::fs::read_to_string(bin_dir.join("is-odd.cmd")).unwrap();
assert!(cmd.contains("node.exe"));
assert!(cmd.contains(".aube"));
let ps1 = std::fs::read_to_string(bin_dir.join("is-odd.ps1")).unwrap();
assert!(ps1.contains("node$exe"));
let sh = std::fs::read_to_string(bin_dir.join("is-odd")).unwrap();
assert!(sh.starts_with("#!/bin/sh"));
}
#[cfg(windows)]
#[test]
fn create_bin_shim_cleans_old_files() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('v1');\n").unwrap();
create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
let cmd1 = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('v2');\n").unwrap();
create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
let cmd2 = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
assert_eq!(cmd1, cmd2);
}
#[cfg(windows)]
#[test]
fn remove_bin_shim_removes_all_files() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "console.log('hi');\n").unwrap();
create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
assert!(bin_dir.join("mycli.cmd").exists());
assert!(bin_dir.join("mycli.ps1").exists());
assert!(bin_dir.join("mycli").exists());
remove_bin_shim(&bin_dir, "mycli");
assert!(!bin_dir.join("mycli.cmd").exists());
assert!(!bin_dir.join("mycli.ps1").exists());
assert!(!bin_dir.join("mycli").exists());
}
#[cfg(unix)]
#[test]
fn create_bin_shim_creates_symlink_on_unix() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(&bin_dir, "mycli", &script, BinShimOptions::default()).unwrap();
let link = bin_dir.join("mycli");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&script).unwrap().permissions().mode();
assert_eq!(mode & 0o755, 0o755);
}
#[test]
#[cfg(unix)]
fn create_bin_shim_creates_parent_for_scoped_bin_name() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join(
"node_modules/.aube/config-inspector@1.4.2/node_modules/@eslint/config-inspector",
);
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("bin.mjs");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"@eslint/config-inspector",
&script,
BinShimOptions {
extend_node_path: true,
prefer_symlinked_executables: Some(false),
},
)
.unwrap();
let shim_path = bin_dir.join("@eslint/config-inspector");
assert!(shim_path.exists());
let content = std::fs::read_to_string(shim_path).unwrap();
let rel = parse_posix_shim_target(&content).expect("shim should carry its marker");
assert_eq!(
rel,
"../../.aube/config-inspector@1.4.2/node_modules/@eslint/config-inspector/bin.mjs",
);
assert!(content.contains("export NODE_PATH=\"$basedir/../..\""));
}
#[test]
fn remove_bin_shim_removes_empty_scoped_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"@scope/mycli",
&script,
BinShimOptions {
extend_node_path: false,
prefer_symlinked_executables: Some(false),
},
)
.unwrap();
assert!(bin_dir.join("@scope").exists());
remove_bin_shim(&bin_dir, "@scope/mycli");
assert!(!bin_dir.join("@scope/mycli").exists());
assert!(!bin_dir.join("@scope").exists());
}
#[cfg(unix)]
#[test]
fn create_bin_shim_writes_posix_shim_when_symlink_opt_out() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"mycli",
&script,
BinShimOptions {
extend_node_path: false,
prefer_symlinked_executables: Some(false),
},
)
.unwrap();
let path = bin_dir.join("mycli");
let meta = path.symlink_metadata().unwrap();
assert!(!meta.file_type().is_symlink());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("#!/bin/sh"));
assert!(content.contains("exec \"$basedir/node\""));
assert!(content.contains(POSIX_SHIM_MARKER_PREFIX));
assert!(!content.contains("NODE_PATH"));
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111);
}
#[cfg(unix)]
#[test]
fn parse_posix_shim_target_round_trips_generator_output() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir
.path()
.join("node_modules/.aube/semver@1.0.0/node_modules/semver");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("bin/semver.js");
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
std::fs::write(&script, "#!/usr/bin/env node\n").unwrap();
create_bin_shim(
&bin_dir,
"semver",
&script,
BinShimOptions {
extend_node_path: true,
prefer_symlinked_executables: Some(false),
},
)
.unwrap();
let content = std::fs::read_to_string(bin_dir.join("semver")).unwrap();
let rel = parse_posix_shim_target(&content).expect("shim should carry its marker");
assert_eq!(
rel,
"../.aube/semver@1.0.0/node_modules/semver/bin/semver.js",
);
}
#[test]
fn parse_posix_shim_target_rejects_foreign_scripts() {
assert!(parse_posix_shim_target("#!/bin/sh\necho hi\n").is_none());
assert!(
parse_posix_shim_target("#!/bin/sh\nexec node \"$basedir/../pkg/cli.js\" \"$@\"\n",)
.is_none()
);
}
#[cfg(unix)]
#[test]
fn create_bin_shim_injects_node_path_in_posix_shim() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"mycli",
&script,
BinShimOptions {
extend_node_path: true,
prefer_symlinked_executables: Some(false),
},
)
.unwrap();
let content = std::fs::read_to_string(bin_dir.join("mycli")).unwrap();
assert!(content.contains("export NODE_PATH=\"$basedir/..\""));
}
#[cfg(unix)]
#[test]
fn create_bin_shim_ignores_node_path_for_symlink() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"mycli",
&script,
BinShimOptions {
extend_node_path: true,
prefer_symlinked_executables: None,
},
)
.unwrap();
let link = bin_dir.join("mycli");
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
}
#[cfg(windows)]
#[test]
fn create_bin_shim_injects_node_path_on_windows() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "#!/usr/bin/env node\nconsole.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"mycli",
&script,
BinShimOptions {
extend_node_path: true,
prefer_symlinked_executables: None,
},
)
.unwrap();
let cmd = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
assert!(cmd.contains("@SET NODE_PATH=%~dp0.."));
let ps1 = std::fs::read_to_string(bin_dir.join("mycli.ps1")).unwrap();
assert!(ps1.contains("$env:NODE_PATH=\"$basedir/..\""));
let sh = std::fs::read_to_string(bin_dir.join("mycli")).unwrap();
assert!(sh.contains("export NODE_PATH=\"$basedir/..\""));
}
#[cfg(windows)]
#[test]
fn create_bin_shim_omits_node_path_when_false() {
let dir = tempfile::tempdir().unwrap();
let bin_dir = dir.path().join("node_modules/.bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let pkg_dir = dir.path().join("pkg");
std::fs::create_dir_all(&pkg_dir).unwrap();
let script = pkg_dir.join("cli.js");
std::fs::write(&script, "console.log('hi');\n").unwrap();
create_bin_shim(
&bin_dir,
"mycli",
&script,
BinShimOptions {
extend_node_path: false,
prefer_symlinked_executables: None,
},
)
.unwrap();
let cmd = std::fs::read_to_string(bin_dir.join("mycli.cmd")).unwrap();
assert!(!cmd.contains("NODE_PATH"));
}
#[test]
fn is_safe_prog_accepts_real_world_interpreters() {
assert!(is_safe_prog("node"));
assert!(is_safe_prog("bash"));
assert!(is_safe_prog("sh"));
assert!(is_safe_prog("python3"));
assert!(is_safe_prog("python3.11"));
assert!(is_safe_prog("ruby"));
assert!(is_safe_prog("deno"));
assert!(is_safe_prog("bun"));
assert!(is_safe_prog("node18"));
assert!(is_safe_prog("node-18"));
assert!(is_safe_prog("pwsh"));
assert!(is_safe_prog("c++"));
assert!(is_safe_prog("ocaml-ng"));
assert!(is_safe_prog("tsx_dev"));
}
#[test]
fn is_safe_prog_rejects_cmd_metachars() {
assert!(!is_safe_prog("node\"&calc&\""));
assert!(!is_safe_prog("node&calc"));
assert!(!is_safe_prog("node|evil"));
assert!(!is_safe_prog("node>out"));
assert!(!is_safe_prog("node<in"));
assert!(!is_safe_prog("node^x"));
assert!(!is_safe_prog("node%PATH%"));
assert!(!is_safe_prog("a b"));
assert!(!is_safe_prog("node;rm"));
assert!(!is_safe_prog("node`evil`"));
assert!(!is_safe_prog("node$(evil)"));
assert!(!is_safe_prog("node\\evil"));
assert!(!is_safe_prog("node/evil"));
assert!(!is_safe_prog("node'evil'"));
}
#[test]
fn is_safe_prog_rejects_non_ascii() {
assert!(!is_safe_prog("node"));
assert!(!is_safe_prog("node\u{00a0}"));
assert!(!is_safe_prog("nöde"));
}
#[test]
fn is_safe_prog_rejects_control_chars() {
assert!(!is_safe_prog("node\0"));
assert!(!is_safe_prog("node\n"));
assert!(!is_safe_prog("node\r"));
assert!(!is_safe_prog("node\t"));
}
#[test]
fn is_safe_prog_rejects_empty_and_oversize() {
assert!(!is_safe_prog(""));
let oversize = "a".repeat(65);
assert!(!is_safe_prog(&oversize));
let at_limit = "a".repeat(64);
assert!(is_safe_prog(&at_limit));
}
#[test]
fn is_safe_prog_rejects_non_alphanumeric_leading_char() {
assert!(!is_safe_prog("-node"));
assert!(!is_safe_prog(".node"));
assert!(!is_safe_prog("_node"));
assert!(!is_safe_prog("+node"));
assert!(is_safe_prog("python3.11"));
assert!(is_safe_prog("node-18"));
assert!(is_safe_prog("tsx_dev"));
assert!(is_safe_prog("c++"));
}
#[test]
fn detect_interpreter_absolute_path_with_cmd_injection_falls_back() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(&script, b"#!/usr/bin/node\"&calc&\"\nbody\n").unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_env_style_with_cmd_injection_falls_back() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(&script, b"#!/usr/bin/env \"node&calc&\"\nbody\n").unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_env_flags_with_cmd_injection_falls_back() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
std::fs::write(&script, b"#!/usr/bin/env \"x&calc.exe&\"\nbody\n").unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn detect_interpreter_fallback_uses_extension() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.sh");
std::fs::write(&script, b"#!/usr/bin/env \"bash&evil&\"\nbody\n").unwrap();
assert_eq!(detect_interpreter(&script), "sh");
}
#[test]
fn detect_interpreter_valid_dotted_version_passes() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.py");
std::fs::write(&script, b"#!/usr/bin/env python3.11\n").unwrap();
assert_eq!(detect_interpreter(&script), "python3.11");
}
#[test]
fn detect_interpreter_long_prog_rejected_falls_back() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("cli.js");
let long = "a".repeat(128);
let shebang = format!("#!/usr/bin/env {long}\nbody\n");
std::fs::write(&script, shebang.as_bytes()).unwrap();
assert_eq!(detect_interpreter(&script), "node");
}
#[test]
fn safe_prog_passes_through_valid() {
assert_eq!(safe_prog("node"), "node");
assert_eq!(safe_prog("python3.11"), "python3.11");
}
#[test]
fn safe_prog_substitutes_on_unsafe() {
assert_eq!(safe_prog("node\"&calc&\""), "node");
assert_eq!(safe_prog(""), "node");
assert_eq!(safe_prog("a b"), "node");
assert_eq!(safe_prog("node\0"), "node");
}
#[cfg(windows)]
#[test]
fn generate_cmd_shim_never_splices_unsafe_prog() {
let shim = generate_cmd_shim("node\"&calc&\"", "..\\pkg\\entry.js", None);
assert!(
!shim.contains("&calc&"),
"unsafe prog spliced into cmd shim:\n{shim}"
);
assert!(
!shim.contains("\"&"),
"stray quote-ampersand in cmd shim:\n{shim}"
);
assert!(shim.contains("node.exe"));
}
#[cfg(windows)]
#[test]
fn generate_ps1_shim_never_splices_unsafe_prog() {
let shim = generate_ps1_shim("bash&rm", "../pkg/entry.js", None);
assert!(
!shim.contains("&rm"),
"unsafe prog spliced into ps1 shim:\n{shim}"
);
}
#[cfg(windows)]
#[test]
fn generate_sh_shim_never_splices_unsafe_prog() {
let shim = generate_sh_shim("sh;rm", "../pkg/entry.js", None);
assert!(
!shim.contains(";rm"),
"unsafe prog spliced into sh shim:\n{shim}"
);
}
#[cfg(unix)]
#[test]
fn generate_posix_shim_never_splices_unsafe_prog() {
let shim = generate_posix_shim("sh;rm", "../pkg/entry.js", None);
assert!(
!shim.contains(";rm"),
"unsafe prog spliced into posix shim:\n{shim}"
);
}
}