From 818698c02e7c7805375e820b2bef55ca4f6eec60 Mon Sep 17 00:00:00 2001 From: Benjamin Western Date: Mon, 27 Jul 2026 14:53:42 +1000 Subject: [PATCH 1/3] fix: launch native package bins directly --- crates/aube-linker/src/sys.rs | 244 ++++++++++++++- crates/aube/src/commands/dlx.rs | 27 +- crates/aube/src/commands/exec.rs | 512 +++++++++++++++++++++---------- docs/package-manager/scripts.md | 5 + test/dlx.bats | 23 ++ test/exec.bats | 20 ++ 6 files changed, 641 insertions(+), 190 deletions(-) diff --git a/crates/aube-linker/src/sys.rs b/crates/aube-linker/src/sys.rs index 6071dfa2b..153313bfc 100644 --- a/crates/aube-linker/src/sys.rs +++ b/crates/aube-linker/src/sys.rs @@ -53,7 +53,8 @@ //! `cmd-shim`, and it avoids the need for Developer Mode or admin //! rights entirely. -use std::io; +use std::ffi::OsString; +use std::io::{self, Read}; use std::path::{Component, Path, PathBuf}; /// Create a directory link from `link` to `target`. @@ -145,6 +146,16 @@ pub struct BinShimOptions<'a> { pub hidden_modules_dir: Option<&'a Path>, } +/// Target and environment recovered from an aube-generated bin wrapper. +/// +/// Paths are resolved against the wrapper's parent. `node_path` is an +/// OS-native path list ready to pass to [`std::process::Command::env`]. +#[derive(Debug, PartialEq, Eq)] +pub struct ResolvedBinShim { + pub target: PathBuf, + pub node_path: Option, +} + /// Create bin shims for a package binary. /// /// - Unix (default / `prefer_symlinked_executables != Some(false)`): @@ -538,7 +549,6 @@ fn shim_node_path( /// Only reads the first 256 bytes — enough for any realistic shebang /// line without pulling large bundled scripts into memory. 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)) @@ -778,6 +788,161 @@ pub fn parse_posix_shim_target(content: &str) -> Option<&str> { None } +/// Maximum wrapper size accepted by [`resolve_bin_shim`]. Generated wrappers +/// are normally under 2 KiB; the larger cap accommodates long Windows paths +/// without reading arbitrary foreign files into memory. +const MAX_BIN_SHIM_BYTES: u64 = 64 * 1024; + +#[derive(Clone, Copy)] +enum BinShimStyle { + Posix, + Cmd, +} + +/// Decode an aube-generated wrapper without executing it. +/// +/// Only regular files at most 64 KiB are inspected. POSIX wrappers must carry +/// aube's versioned marker; cmd wrappers must match the generated `@SETLOCAL` +/// and local-interpreter branch shape. Symlinks and unrecognized wrappers +/// return `Ok(None)`. +pub fn resolve_bin_shim(path: &Path) -> io::Result> { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() || metadata.len() > MAX_BIN_SHIM_BYTES { + return Ok(None); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::fs::File::open(path)? + .take(MAX_BIN_SHIM_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_BIN_SHIM_BYTES { + return Ok(None); + } + let Ok(content) = std::str::from_utf8(&bytes) else { + return Ok(None); + }; + let Some(parent) = path.parent() else { + return Ok(None); + }; + + let parsed = if let Some(target) = parse_posix_shim_target(content) { + Some(( + BinShimStyle::Posix, + target, + content.lines().find_map(|line| { + line.strip_prefix("export NODE_PATH=\"") + .and_then(|value| value.strip_suffix('"')) + }), + )) + } else { + parse_cmd_shim_target(content).map(|target| { + ( + BinShimStyle::Cmd, + target, + content + .lines() + .find_map(|line| line.strip_prefix("@SET NODE_PATH=")) + .map(|value| value.trim_end_matches('\r')), + ) + }) + }; + let Some((style, target, raw_node_path)) = parsed else { + return Ok(None); + }; + let Some(target) = resolve_shim_relative_path(parent, target, style) else { + return Ok(None); + }; + + let node_path = match raw_node_path { + Some(value) => { + let Some(node_path) = resolve_shim_node_path(parent, value, style) else { + return Ok(None); + }; + Some(node_path) + } + None => None, + }; + + Ok(Some(ResolvedBinShim { target, node_path })) +} + +fn parse_cmd_shim_target(content: &str) -> Option<&str> { + let mut lines = content.lines(); + if lines.next()?.trim_end_matches('\r') != "@SETLOCAL" { + return None; + } + + let mut line = lines.next()?.trim_end_matches('\r'); + if line.starts_with("@SET NODE_PATH=") { + line = lines.next()?.trim_end_matches('\r'); + } + + let if_prefix = "@IF EXIST \"%~dp0\\"; + let program = line.strip_prefix(if_prefix)?.strip_suffix(".exe\" (")?; + if !is_safe_prog(program) { + return None; + } + + let local_line = lines.next()?.trim_end_matches('\r'); + let target = local_line + .strip_prefix(" \"%~dp0\\")? + .strip_prefix(program)? + .strip_prefix(".exe\" \"%~dp0\\")? + .strip_suffix("\" %*")?; + if lines.next()?.trim_end_matches('\r') != ") ELSE (" + || lines.next()?.trim_end_matches('\r') != " @SET PATHEXT=%PATHEXT:;.JS;=;%" + { + return None; + } + + let fallback_target = lines + .next()? + .trim_end_matches('\r') + .strip_prefix(" ")? + .strip_prefix(program)? + .strip_prefix(" \"%~dp0\\")? + .strip_suffix("\" %*")?; + if fallback_target != target || lines.next()?.trim_end_matches('\r') != ")" { + return None; + } + lines.next().is_none().then_some(target) +} + +fn resolve_shim_relative_path( + parent: &Path, + relative: &str, + style: BinShimStyle, +) -> Option { + if relative.is_empty() + || relative.contains('\0') + || relative.starts_with('/') + || relative.starts_with('\\') + || relative.len() >= 2 && relative.as_bytes()[1] == b':' + { + return None; + } + let relative = match style { + BinShimStyle::Posix => relative.to_string(), + BinShimStyle::Cmd => relative.replace('\\', std::path::MAIN_SEPARATOR_STR), + }; + Some(normalize_path(&parent.join(relative))) +} + +fn resolve_shim_node_path(parent: &Path, value: &str, style: BinShimStyle) -> Option { + let (separator, prefix) = match style { + BinShimStyle::Posix => (':', "$basedir/"), + BinShimStyle::Cmd => (';', "%~dp0"), + }; + let paths = value + .split(separator) + .map(|entry| { + let relative = entry.strip_prefix(prefix)?; + resolve_shim_relative_path(parent, relative, style) + }) + .collect::>>()?; + std::env::join_paths(paths).ok() +} + /// Collapse `.` / `..` components without touching the filesystem. /// Used on Windows to give `junction::create` an absolute target when /// the caller computed a relative `../../foo` — `canonicalize` isn't @@ -1296,6 +1461,75 @@ mod tests { ); } + #[test] + fn resolve_bin_shim_rejects_oversized_and_foreign_files() { + let dir = tempfile::tempdir().unwrap(); + let oversized = dir.path().join("oversized"); + std::fs::write(&oversized, vec![b'x'; MAX_BIN_SHIM_BYTES as usize + 1]).unwrap(); + assert_eq!(resolve_bin_shim(&oversized).unwrap(), None); + + let foreign = dir.path().join("foreign.cmd"); + std::fs::write( + &foreign, + "@SETLOCAL\r\n\ + @IF EXIST \"%~dp0\\node.exe\" (\r\n\ + \x20 \"%~dp0\\node.exe\" \"%~dp0\\payload.exe\" %*\r\n\ + ) ELSE (\r\n\ + \x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\ + \x20 node \"%~dp0\\payload.exe\" %*\r\n\ + )\r\n\ + @ECHO foreign behavior\r\n", + ) + .unwrap(); + assert_eq!(resolve_bin_shim(&foreign).unwrap(), None); + + let malformed_env = dir.path().join("malformed-env"); + std::fs::write( + &malformed_env, + "#!/bin/sh\n\ + # aube-bin-shim v1 target=pkg/tool\n\ + export NODE_PATH=\"not-basedir-relative\"\n", + ) + .unwrap(); + assert_eq!(resolve_bin_shim(&malformed_env).unwrap(), None); + } + + #[test] + fn resolve_bin_shim_decodes_cmd_target_and_multi_entry_node_path() { + let dir = tempfile::tempdir().unwrap(); + let bin_dir = dir.path().join("node_modules/.bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let shim = bin_dir.join("tool.cmd"); + std::fs::write( + &shim, + "@SETLOCAL\r\n\ + @SET NODE_PATH=%~dp0..;%~dp0..\\.aube\\node_modules\r\n\ + @IF EXIST \"%~dp0\\node.exe\" (\r\n\ + \x20 \"%~dp0\\node.exe\" \"%~dp0\\..\\pkg\\tool.exe\" %*\r\n\ + ) ELSE (\r\n\ + \x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\ + \x20 node \"%~dp0\\..\\pkg\\tool.exe\" %*\r\n\ + )\r\n", + ) + .unwrap(); + + let resolved = resolve_bin_shim(&shim).unwrap().unwrap(); + assert_eq!( + resolved.target, + dir.path().join("node_modules/pkg/tool.exe") + ); + assert_eq!( + resolved.node_path, + Some( + std::env::join_paths([ + dir.path().join("node_modules"), + dir.path().join("node_modules/.aube/node_modules"), + ]) + .unwrap() + ) + ); + } + #[cfg(unix)] #[test] fn create_bin_shim_injects_node_path_in_posix_shim() { @@ -1359,6 +1593,12 @@ mod tests { content.contains("export NODE_PATH=\"$basedir/..:$basedir/../.aube/node_modules\""), "expected two-entry NODE_PATH, got:\n{content}" ); + let resolved = resolve_bin_shim(&bin_dir.join("mycli")).unwrap().unwrap(); + assert_eq!(resolved.target, script); + assert_eq!( + resolved.node_path, + Some(std::env::join_paths([dir.path().join("node_modules"), hidden]).unwrap()) + ); } #[cfg(unix)] diff --git a/crates/aube/src/commands/dlx.rs b/crates/aube/src/commands/dlx.rs index 4620467fa..8f8fd5ec1 100644 --- a/crates/aube/src/commands/dlx.rs +++ b/crates/aube/src/commands/dlx.rs @@ -283,25 +283,14 @@ pub async fn run_in( aube_util::cmd("dlx") )); } - // The linker writes three shims for every bin on Windows: - // `.cmd`, `.ps1`, and a bare extensionless sh shim - // (for use under bash / git-bash). CreateProcess can only - // launch real PE executables and `.cmd`/`.bat` files — handing - // it the sh shim fails with `%1 is not a valid Win32 application` - // (os error 193). Prefer the `.cmd` shim on Windows; on Unix - // the bare shim is the executable. - let exec_path = super::exec::resolve_exec_shim(&bin_path); - let mut cmd = tokio::process::Command::new(&exec_path); - cmd.args(&bin_args) - .current_dir(&prev_cwd) - .stderr(aube_scripts::child_stderr()); - // Shebang shims resolve `node` through PATH — give them the - // switched runtime. - let runtime_dirs = crate::runtime::path_entries(); - if !runtime_dirs.is_empty() { - cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); - } - crate::runtime::apply_child_env(&mut cmd); + let cmd = super::exec::build_bin_command( + &prev_cwd, + &bin_path, + &resolved_bin_name, + &bin_args, + &[], + false, + ); crate::process_guard::spawn_and_wait(cmd) .await .into_diagnostic() diff --git a/crates/aube/src/commands/exec.rs b/crates/aube/src/commands/exec.rs index 146d55985..348496956 100644 --- a/crates/aube/src/commands/exec.rs +++ b/crates/aube/src/commands/exec.rs @@ -394,7 +394,7 @@ fn bin_not_found_error(bin: &str) -> miette::Report { /// Assemble the `tokio::process::Command` that runs `bin`, shared by the /// supervised (`spawn_and_wait`) and image-replacing (`exec_bin_terminal`) /// paths. Callers own the bin-exists check. -fn build_bin_command( +pub(crate) fn build_bin_command( cwd: &Path, bin_path: &Path, bin: &str, @@ -402,34 +402,33 @@ fn build_bin_command( node_args: &[String], shell_mode: bool, ) -> tokio::process::Command { - let mut command = if let Some(cmd) = node_bin_command(bin_path, args, node_args, shell_mode) { - cmd - } else if shell_mode { - let line = std::iter::once(aube_scripts::shell_quote_arg(bin)) - .chain(args.iter().map(|arg| aube_scripts::shell_quote_arg(arg))) - .collect::>() - .join(" "); - let bin_dir = super::project_modules_dir(cwd).join(".bin"); - let mut path_dirs = vec![bin_dir]; - path_dirs.extend(crate::runtime::path_entries()); - let new_path = aube_scripts::prepend_paths(&path_dirs); - let mut cmd = aube_scripts::spawn_shell(&line); - cmd.env("PATH", &new_path); - cmd - } else { - let exec_path = resolve_exec_shim(bin_path); - let mut cmd = tokio::process::Command::new(exec_path); - cmd.args(args); - // `#!/usr/bin/env node` shebangs resolve through the child's PATH — - // put the switched runtime ahead of the inherited one so the bin - // (and, on the image-replacing path, the exec'd shim) honors the - // project's `.nvmrc` / `devEngines` instead of the ambient node. - let runtime_dirs = crate::runtime::path_entries(); - if !runtime_dirs.is_empty() { - cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); - } - cmd - }; + let mut command = + if !shell_mode && let Some(cmd) = resolved_bin_command(bin_path, args, node_args) { + cmd + } else if shell_mode { + let line = std::iter::once(aube_scripts::shell_quote_arg(bin)) + .chain(args.iter().map(|arg| aube_scripts::shell_quote_arg(arg))) + .collect::>() + .join(" "); + let bin_dir = super::project_modules_dir(cwd).join(".bin"); + let mut path_dirs = vec![bin_dir]; + path_dirs.extend(crate::runtime::path_entries()); + let new_path = aube_scripts::prepend_paths(&path_dirs); + let mut cmd = aube_scripts::spawn_shell(&line); + cmd.env("PATH", &new_path); + cmd + } else { + let exec_path = resolve_exec_shim(bin_path); + let mut cmd = tokio::process::Command::new(exec_path); + cmd.args(args); + // `#!/usr/bin/env node` shebangs resolve through the child's PATH + // so the generated shim must see the project's switched runtime. + let runtime_dirs = crate::runtime::path_entries(); + if !runtime_dirs.is_empty() { + cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); + } + cmd + }; crate::runtime::apply_child_env(&mut command); command .current_dir(cwd) @@ -465,48 +464,41 @@ pub(crate) async fn exec_bin_status_with_node_args( )); } - let mut command = if let Some(cmd) = node_bin_command(bin_path, args, node_args, shell_mode) { - cmd - } else if shell_mode { - let line = std::iter::once(aube_scripts::shell_quote_arg(bin)) - .chain(args.iter().map(|arg| aube_scripts::shell_quote_arg(arg))) - .collect::>() - .join(" "); - let bin_dir = super::project_modules_dir(cwd).join(".bin"); - let mut path_dirs = vec![bin_dir]; - path_dirs.extend(crate::runtime::path_entries()); - let new_path = aube_scripts::prepend_paths(&path_dirs); - let mut cmd = aube_scripts::spawn_shell(&line); - cmd.env("PATH", &new_path); - cmd - } else { - let exec_path = resolve_exec_shim(bin_path); - let mut cmd = tokio::process::Command::new(exec_path); - cmd.args(args); - // `#!/usr/bin/env node` shebangs resolve through the child's - // PATH — put the switched runtime ahead of the inherited one. - let runtime_dirs = crate::runtime::path_entries(); - if !runtime_dirs.is_empty() { - cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); - } - cmd - }; - crate::runtime::apply_child_env(&mut command); - command.current_dir(cwd); + let command = build_bin_command(cwd, bin_path, bin, args, node_args, shell_mode); super::run_output::run_command(command, output_mode).await } -fn node_bin_command( +/// Resolve an aube-generated shim (or symlink) to its package target and +/// choose the launcher that understands that file. Native executable formats +/// run directly. JavaScript targets are unwrapped only when `node_args` must +/// be injected; otherwise they and other interpreters retain the established +/// generated-shim behavior. +fn resolved_bin_command( bin_path: &Path, args: &[String], node_args: &[String], - shell_mode: bool, ) -> Option { - if shell_mode || node_args.is_empty() { - return None; + let target = resolve_bin_target(bin_path); + if is_native_executable(&target.path) { + let mut cmd = tokio::process::Command::new(&target.path); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.as_std_mut().arg0(bin_path); + } + if let Some(node_path) = &target.node_path { + cmd.env("NODE_PATH", node_path); + } + // Preserve the generated shim's switched-runtime behavior for native + // launchers that spawn Node themselves. + let runtime_dirs = crate::runtime::path_entries(); + if !runtime_dirs.is_empty() { + cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); + } + cmd.args(args); + return Some(cmd); } - let target = resolve_node_bin_target(bin_path)?; - if !is_node_backed_bin(&target.path) { + if node_args.is_empty() || !is_node_backed_bin(&target.path) { return None; } let mut cmd = @@ -518,87 +510,112 @@ fn node_bin_command( Some(cmd) } -struct NodeBinTarget { +struct BinTarget { path: std::path::PathBuf, node: Option, - node_path: Option, + node_path: Option, } -fn resolve_node_bin_target(bin_path: &Path) -> Option { +fn resolve_bin_target(bin_path: &Path) -> BinTarget { let path = resolve_exec_shim(bin_path); - resolve_node_bin_target_path(&path).or(Some(NodeBinTarget { - path, - node: None, - node_path: None, - })) -} - -fn resolve_node_bin_target_path(path: &Path) -> Option { - if let Ok(target) = std::fs::read_link(path) { - let path = if target.is_absolute() { + if let Ok(target) = std::fs::read_link(&path) { + let target = if target.is_absolute() { target + } else if let Some(parent) = path.parent() { + // Keep `..` components intact. Resolving them lexically can change + // meaning when an earlier component is itself a symlink. + parent.join(target) } else { - aube_linker::normalize_path(&path.parent()?.join(target)) + target }; - return Some(NodeBinTarget { - path, + return BinTarget { + path: target, node: None, node_path: None, - }); - } - let content = std::fs::read_to_string(path).ok()?; - let parent = path.parent()?; - if let Some(rel) = aube_linker::parse_posix_shim_target(&content) { - return Some(NodeBinTarget { - path: aube_linker::normalize_path(&parent.join(rel)), - node: local_node_program(parent), - node_path: parse_posix_node_path(&content) - .map(|rel| aube_linker::normalize_path(&parent.join(rel))), - }); - } - let rel = parse_cmd_shim_target(&content)?; - Some(NodeBinTarget { - path: aube_linker::normalize_path(&parent.join(rel)), - node: local_node_program(parent), - node_path: parse_cmd_node_path(&content) - .map(|rel| aube_linker::normalize_path(&parent.join(rel))), - }) -} - -fn parse_cmd_shim_target(content: &str) -> Option<&str> { - let marker = "\"%~dp0\\"; - let mut rest = content; - while let Some(start) = rest.find(marker) { - let after_marker = &rest[start + marker.len()..]; - let Some(end) = after_marker.find('"') else { - break; }; - let candidate = &after_marker[..end]; - if !candidate.ends_with(".exe") { - return Some(candidate); - } - rest = &after_marker[end + 1..]; } - None + if let Ok(Some(shim)) = aube_linker::sys::resolve_bin_shim(&path) { + return BinTarget { + path: shim.target, + node: path.parent().and_then(local_node_program), + node_path: shim.node_path, + }; + } + BinTarget { + path, + node: None, + node_path: None, + } } -fn parse_cmd_node_path(content: &str) -> Option<&str> { - let rest = content - .lines() - .find_map(|line| line.strip_prefix("@SET NODE_PATH=%~dp0"))?; - Some(rest.trim_end_matches('\r')) +fn local_node_program(parent: &Path) -> Option { + let node = parent.join(if cfg!(windows) { "node.exe" } else { "node" }); + node.exists().then_some(node) } -fn parse_posix_node_path(content: &str) -> Option<&str> { - content.lines().find_map(|line| { - line.strip_prefix("export NODE_PATH=\"$basedir/") - .and_then(|rest| rest.strip_suffix('"')) - }) +fn is_native_executable(target: &Path) -> bool { + use std::io::{Read, Seek}; + + let mut header = [0u8; 64]; + let (mut file, n) = match std::fs::File::open(target) { + Ok(mut file) => { + let n = file.read(&mut header).unwrap_or(0); + (Some(file), n) + } + Err(_) => (None, 0), + }; + if is_native_magic(&header[..n.min(4)]) { + return true; + } + if n >= 64 && header.starts_with(b"MZ") { + let pe_offset = + u32::from_le_bytes([header[0x3c], header[0x3d], header[0x3e], header[0x3f]]); + let mut signature = [0u8; 4]; + if let Some(file) = &mut file + && file + .seek(std::io::SeekFrom::Start(u64::from(pe_offset))) + .is_ok() + && file.read_exact(&mut signature).is_ok() + && signature == *b"PE\0\0" + { + return true; + } + } + + #[cfg(windows)] + { + // A shebang is stronger evidence than a Windows-looking suffix. This + // keeps intentionally interpreter-backed polyglot bins on their shim. + if header[..n].starts_with(b"#!") { + return false; + } + return target + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + ["exe", "cmd", "bat", "com"] + .iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + }); + } + + #[cfg(not(windows))] + false } -fn local_node_program(parent: &Path) -> Option { - let node = parent.join(if cfg!(windows) { "node.exe" } else { "node" }); - node.exists().then_some(node) +fn is_native_magic(magic: &[u8]) -> bool { + magic.starts_with(b"\x7fELF") + || matches!( + magic, + [0xcf, 0xfa, 0xed, 0xfe] + | [0xfe, 0xed, 0xfa, 0xcf] + | [0xce, 0xfa, 0xed, 0xfe] + | [0xfe, 0xed, 0xfa, 0xce] + | [0xca, 0xfe, 0xba, 0xbe] + | [0xbe, 0xba, 0xfe, 0xca] + | [0xca, 0xfe, 0xba, 0xbf] + | [0xbf, 0xba, 0xfe, 0xca] + ) } fn is_node_backed_bin(target: &Path) -> bool { @@ -651,11 +668,11 @@ fn is_node_interpreter(raw: &str) -> bool { pub(crate) fn resolve_exec_shim(bin_path: &Path) -> std::path::PathBuf { #[cfg(windows)] { - if bin_path.extension().is_none() { - let cmd_path = bin_path.with_extension("cmd"); - if cmd_path.exists() { - return cmd_path; - } + let mut cmd_path = bin_path.as_os_str().to_os_string(); + cmd_path.push(".cmd"); + let cmd_path = std::path::PathBuf::from(cmd_path); + if cmd_path.exists() { + return cmd_path; } } bin_path.to_path_buf() @@ -663,10 +680,10 @@ pub(crate) fn resolve_exec_shim(bin_path: &Path) -> std::path::PathBuf { #[cfg(test)] mod tests { - use super::resolve_node_bin_target; + use super::resolve_bin_target; use super::{ - is_node_backed_bin, parse_cmd_node_path, parse_cmd_shim_target, parse_posix_node_path, - resolve_exec_shim, + build_bin_command, is_native_executable, is_native_magic, is_node_backed_bin, + resolve_exec_shim, resolved_bin_command, }; #[test] @@ -688,6 +705,17 @@ mod tests { assert_eq!(resolve_exec_shim(&bare), cmd_shim); } + #[cfg(windows)] + #[test] + fn resolve_exec_shim_appends_cmd_to_dotted_bin_name() { + let tmp = tempfile::tempdir().unwrap(); + let bare = tmp.path().join("tool.exe"); + let cmd_shim = tmp.path().join("tool.exe.cmd"); + std::fs::write(&bare, b"#!/bin/sh\n").unwrap(); + std::fs::write(&cmd_shim, b"@echo off\n").unwrap(); + assert_eq!(resolve_exec_shim(&bare), cmd_shim); + } + #[cfg(unix)] #[test] fn resolve_exec_shim_keeps_bare_path_on_unix() { @@ -701,51 +729,31 @@ mod tests { #[cfg(unix)] #[test] - fn resolve_node_bin_target_follows_symlink() { + fn resolve_bin_target_follows_symlink_without_canonicalizing() { let tmp = tempfile::tempdir().unwrap(); let target = tmp.path().join("bin.js"); let shim = tmp.path().join("shim"); std::fs::write(&target, b"#!/usr/bin/env node\n").unwrap(); std::os::unix::fs::symlink("bin.js", &shim).unwrap(); - let resolved = resolve_node_bin_target(&shim).unwrap(); + let resolved = resolve_bin_target(&shim); assert_eq!(resolved.path, target); assert_eq!(resolved.node, None); assert_eq!(resolved.node_path, None); } + #[cfg(unix)] #[test] - fn parse_cmd_shim_target_skips_program_exe() { - let content = "@SETLOCAL\r\n\ - @IF EXIST \"%~dp0\\node.exe\" (\r\n\ - \x20 \"%~dp0\\node.exe\" \"%~dp0\\pkg\\bin.js\" %*\r\n\ - ) ELSE (\r\n\ - \x20 node \"%~dp0\\pkg\\bin.js\" %*\r\n\ - )\r\n"; - - assert_eq!(parse_cmd_shim_target(content), Some("pkg\\bin.js")); - } - - #[test] - fn parse_cmd_shim_target_stops_on_truncated_marker() { - assert_eq!(parse_cmd_shim_target("\"%~dp0\\node.exe"), None); - } - - #[test] - fn parse_cmd_node_path_reads_generated_env() { - let content = "@SETLOCAL\r\n@SET NODE_PATH=%~dp0..\\..\r\n"; - - assert_eq!(parse_cmd_node_path(content), Some("..\\..")); - } - - #[test] - fn parse_posix_node_path_reads_generated_env() { - let content = "#!/bin/sh\nexport NODE_PATH=\"$basedir/../..\"\n"; + fn resolve_bin_target_preserves_parent_components_after_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let shim = tmp.path().join("shim"); + std::os::unix::fs::symlink("pivot/../bin", &shim).unwrap(); - assert_eq!(parse_posix_node_path(content), Some("../..")); + let resolved = resolve_bin_target(&shim); + assert_eq!(resolved.path, tmp.path().join("pivot/../bin")); } #[test] - fn resolve_node_bin_target_preserves_posix_shim_env() { + fn resolve_bin_target_preserves_posix_shim_env() { let tmp = tempfile::tempdir().unwrap(); let target = tmp.path().join("pkg").join("bin.js"); std::fs::create_dir_all(target.parent().unwrap()).unwrap(); @@ -767,15 +775,18 @@ mod tests { ) .unwrap(); - let resolved = resolve_node_bin_target(&shim).unwrap(); + let resolved = resolve_bin_target(&shim); assert_eq!(resolved.path, target); assert_eq!(resolved.node, Some(local_node)); - assert_eq!(resolved.node_path, Some(node_path)); + assert_eq!( + resolved.node_path, + Some(std::env::join_paths([node_path]).unwrap()) + ); } #[cfg(windows)] #[test] - fn resolve_node_bin_target_reads_cmd_shim_on_windows() { + fn resolve_bin_target_reads_cmd_shim_on_windows() { let tmp = tempfile::tempdir().unwrap(); let target = tmp.path().join("pkg").join("bin.js"); std::fs::create_dir_all(target.parent().unwrap()).unwrap(); @@ -802,10 +813,13 @@ mod tests { ) .unwrap(); - let resolved = resolve_node_bin_target(&bare).unwrap(); + let resolved = resolve_bin_target(&bare); assert_eq!(resolved.path, target); assert_eq!(resolved.node, Some(local_node)); - assert_eq!(resolved.node_path, Some(node_path)); + assert_eq!( + resolved.node_path, + Some(std::env::join_paths([node_path]).unwrap()) + ); } #[test] @@ -840,4 +854,164 @@ mod tests { std::fs::write(&target, b"#!/usr/bin/nodejs\nconsole.log(1)\n").unwrap(); assert!(is_node_backed_bin(&target)); } + + #[test] + fn resolved_bin_command_keeps_js_shim_without_node_args() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("pkg/bin.js"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, b"#!/usr/bin/env node\n").unwrap(); + let shim = tmp.path().join("tool"); + std::fs::write(&shim, "#!/bin/sh\n# aube-bin-shim v1 target=pkg/bin.js\n").unwrap(); + + assert!(resolved_bin_command(&shim, &[], &[]).is_none()); + } + + #[test] + fn native_magic_recognizes_supported_executable_formats() { + for magic in [ + b"\x7fELF".as_slice(), + b"\xcf\xfa\xed\xfe".as_slice(), + b"\xfe\xed\xfa\xcf".as_slice(), + b"\xce\xfa\xed\xfe".as_slice(), + b"\xfe\xed\xfa\xce".as_slice(), + b"\xca\xfe\xba\xbe".as_slice(), + b"\xbe\xba\xfe\xca".as_slice(), + b"\xca\xfe\xba\xbf".as_slice(), + b"\xbf\xba\xfe\xca".as_slice(), + ] { + assert!(is_native_magic(magic), "unrecognized magic: {magic:x?}"); + } + assert!(!is_native_magic(b"#!/u")); + } + + #[test] + fn pe_detection_requires_the_pe_signature() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("tool"); + let mut pe = vec![0u8; 68]; + pe[..2].copy_from_slice(b"MZ"); + pe[0x3c..0x40].copy_from_slice(&64u32.to_le_bytes()); + pe[64..68].copy_from_slice(b"PE\0\0"); + std::fs::write(&target, pe).unwrap(); + assert!(is_native_executable(&target)); + + std::fs::write(&target, b"MZ = 1; console.log(MZ);\n").unwrap(); + assert!(!is_native_executable(&target)); + } + + #[test] + fn native_executable_detection_reads_magic_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("tool"); + std::fs::write(&target, b"\x7fELFpayload").unwrap(); + + assert!(is_native_executable(&target)); + } + + #[cfg(windows)] + #[test] + fn native_executable_detection_accepts_windows_extensions() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["tool.EXE", "tool.cmd", "tool.Bat", "tool.com"] { + let target = tmp.path().join(name); + std::fs::write(&target, b"not magic").unwrap(); + assert!(is_native_executable(&target), "rejected {name}"); + } + } + + #[cfg(windows)] + #[test] + fn native_executable_detection_preserves_shebang_targets() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("tool.exe"); + std::fs::write(&target, b"#!/usr/bin/env node\n").unwrap(); + + assert!(!is_native_executable(&target)); + } + + #[cfg(windows)] + #[tokio::test] + async fn bin_command_executes_windows_target_from_dotted_generated_cmd_shim() { + let tmp = tempfile::tempdir().unwrap(); + let bin_dir = tmp.path().join("node_modules/.bin"); + let target = std::env::var_os("COMSPEC") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows\System32\cmd.exe")); + aube_linker::create_bin_shim( + &bin_dir, + "native-tool.exe", + &target, + aube_linker::BinShimOptions::default(), + ) + .unwrap(); + + let mut command = build_bin_command( + tmp.path(), + &bin_dir.join("native-tool.exe"), + "native-tool.exe", + &[ + "/D".to_string(), + "/S".to_string(), + "/C".to_string(), + "echo launched-directly".to_string(), + ], + &[], + false, + ); + let output = command.output().await.unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "launched-directly" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn bin_command_executes_native_target_behind_generated_shim() { + let tmp = tempfile::tempdir().unwrap(); + let bin_dir = tmp.path().join("node_modules/.bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let hidden_modules = tmp.path().join("node_modules/.aube/node_modules"); + std::fs::create_dir_all(&hidden_modules).unwrap(); + let target = std::path::Path::new("/bin/echo"); + aube_linker::create_bin_shim( + &bin_dir, + "native-echo", + target, + aube_linker::BinShimOptions { + extend_node_path: true, + prefer_symlinked_executables: Some(false), + hidden_modules_dir: Some(&hidden_modules), + }, + ) + .unwrap(); + + let shim = bin_dir.join("native-echo"); + let mut command = build_bin_command( + tmp.path(), + &shim, + "native-echo", + &["launched-directly".to_string()], + &[], + false, + ); + let node_path = command + .as_std() + .get_envs() + .find_map(|(key, value)| (key == "NODE_PATH").then_some(value).flatten()) + .unwrap(); + assert_eq!( + node_path, + std::env::join_paths([tmp.path().join("node_modules"), hidden_modules.clone()]) + .unwrap() + ); + let output = command.output().await.unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "launched-directly\n" + ); + } } diff --git a/docs/package-manager/scripts.md b/docs/package-manager/scripts.md index 3aad91176..40e8d9d72 100644 --- a/docs/package-manager/scripts.md +++ b/docs/package-manager/scripts.md @@ -42,6 +42,11 @@ aube exec tsc -- --noEmit `exec` runs a binary from the project context with `node_modules/.bin` on `PATH`. +Non-shell `aube exec`, the `aube run` bin fallback, and non-shell `aube dlx` +detect native package targets and launch them directly. Shell-routed commands, +global bins, and direct `.bin` invocation continue through installed wrappers; +on Unix, directly launched native targets must be executable. + ## One-off binaries ```sh diff --git a/test/dlx.bats b/test/dlx.bats index f4945fa79..aaa47fb21 100644 --- a/test/dlx.bats +++ b/test/dlx.bats @@ -63,6 +63,29 @@ teardown() { assert_line "local-dlx:alpha,beta" } +@test "aube dlx launches a native bin from a scratch install" { + case "$(uname -s)" in + Darwin | Linux) ;; + *) skip "native fixture requires a Unix executable" ;; + esac + mkdir -p tools/native-bin + cat >tools/native-bin/package.json <<-'JSON' + { + "name": "native-bin", + "version": "1.0.0", + "bin": { + "native-aube": "native-aube" + } + } + JSON + cp "$PROJECT_ROOT/target/debug/aube" tools/native-bin/native-aube + chmod +x tools/native-bin/native-aube + + run aube dlx --package "file:$PWD/tools/native-bin" native-aube --version + assert_success + assert_output --regexp '\([0-9]{4}-[0-9]{2}-[0-9]{2}\)$' +} + @test "aube dlx -p installs a different package than the bin name" { # The `which` npm package ships a binary named `node-which`, not `which`. # Running `node-which node` prints the absolute path of the `node` diff --git a/test/exec.bats b/test/exec.bats index d2c17d6da..8cede033e 100644 --- a/test/exec.bats +++ b/test/exec.bats @@ -9,6 +9,26 @@ teardown() { _common_teardown } +@test "aube exec launches a native package bin directly" { + case "$(uname -s)" in + Darwin | Linux) ;; + *) skip "native fixture requires a Unix executable" ;; + esac + cat >package.json <<'EOF' +{"name":"native-root","version":"1.0.0","bin":{"native-echo":"native-echo"}} +EOF + ln -s /bin/echo native-echo + + run aube install + assert_success + run aube exec --no-install native-echo launched-directly + assert_success + assert_output "launched-directly" + run aube run --no-install native-echo launched-through-run + assert_success + assert_output "launched-through-run" +} + @test "aube exec --parallel fans out across workspace packages" { cat >pnpm-workspace.yaml <<'EOF' packages: From 0347eac583c8b88b2e977e815285880cd3f72d9c Mon Sep 17 00:00:00 2001 From: Benjamin Western Date: Mon, 27 Jul 2026 15:19:02 +1000 Subject: [PATCH 2/3] test(exec): match generated cmd shim template --- crates/aube/src/commands/exec.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/aube/src/commands/exec.rs b/crates/aube/src/commands/exec.rs index 348496956..dd324f13c 100644 --- a/crates/aube/src/commands/exec.rs +++ b/crates/aube/src/commands/exec.rs @@ -808,6 +808,7 @@ mod tests { @IF EXIST \"%~dp0\\node.exe\" (\r\n\ \x20 \"%~dp0\\node.exe\" \"%~dp0\\pkg\\bin.js\" %*\r\n\ ) ELSE (\r\n\ + \x20 @SET PATHEXT=%PATHEXT:;.JS;=;%\r\n\ \x20 node \"%~dp0\\pkg\\bin.js\" %*\r\n\ )\r\n", ) From 63a99b37f83133be9ce48922a3460cd06d38e3ef Mon Sep 17 00:00:00 2001 From: Benjamin Western Date: Mon, 27 Jul 2026 15:26:26 +1000 Subject: [PATCH 3/3] test(linker): use native separators in shim expectation --- crates/aube-linker/src/sys.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/aube-linker/src/sys.rs b/crates/aube-linker/src/sys.rs index d9fb86381..74bc0da97 100644 --- a/crates/aube-linker/src/sys.rs +++ b/crates/aube-linker/src/sys.rs @@ -1737,7 +1737,10 @@ mod tests { Some( std::env::join_paths([ dir.path().join("node_modules"), - dir.path().join("node_modules/.aube/node_modules"), + dir.path() + .join("node_modules") + .join(".aube") + .join("node_modules"), ]) .unwrap() )