Skip to main content

aube_linker/
sys.rs

1//! Platform-specific directory-link and bin-shim creation.
2//!
3//! ## Directory links ([`create_dir_link`])
4//!
5//! On Unix, [`create_dir_link`] is a thin wrapper around
6//! `std::os::unix::fs::symlink` — same semantics as any other
7//! symlink-based linker.
8//!
9//! On Windows, [`create_dir_link`] creates an **NTFS junction**
10//! rather than a real symlink. Junctions don't require Developer
11//! Mode or admin rights, which is the whole reason pnpm and npm use
12//! them for `node_modules` tree layout on Windows (they go through
13//! Node's `fs.symlink(target, path, 'junction')`, which translates
14//! to the same `FSCTL_SET_REPARSE_POINT` dance the `junction` crate
15//! wraps). Real Windows symlinks via `std::os::windows::fs::
16//! symlink_dir` would require either elevated privileges or
17//! Developer Mode — neither of which is available on GitHub-hosted
18//! `windows-latest` runners or on vanilla Windows developer
19//! machines, so using real symlinks would break installs in both
20//! places.
21//!
22//! There is one wrinkle vs. Unix symlinks that callers must honor:
23//! **Junctions only accept absolute targets.** If the caller passes
24//! a relative target, this helper resolves it against the link's
25//! parent directory before handing it to `junction::create`.
26//!
27//! ## Bin shims ([`create_bin_shim`])
28//!
29//! Two dials control the shape of each entry:
30//!
31//! - `prefer_symlinked_executables` (POSIX only). Default `None` is
32//!   "platform default", which on POSIX is a plain symlink — same as
33//!   pnpm's `preferSymlinkedExecutables=true`. `Some(false)` falls
34//!   back to a shell-script shim matching the Windows shell wrapper;
35//!   callers opt into this when they need `extendNodePath` to
36//!   actually set `NODE_PATH` (a bare symlink can't export env vars).
37//!   Windows never creates real symlinks here — Developer Mode /
38//!   admin rights would be required, and both are commonly absent on
39//!   CI and developer machines.
40//!
41//! - `extend_node_path`. When `true`, shell/cmd/powershell shims set
42//!   `NODE_PATH` to `$basedir/..` (the top-level `node_modules`) so
43//!   the shimmed binary can resolve modules regardless of where it's
44//!   invoked from. Matches pnpm's `extendNodePath=true`. No-op when
45//!   the final output is a symlink (POSIX default) — symlinks can't
46//!   export env vars, which is why callers who care pair it with
47//!   `prefer_symlinked_executables=false`.
48//!
49//! On Windows, `create_bin_shim` writes three plain-text wrapper
50//! scripts into the bin directory — `.cmd` (for cmd.exe), `.ps1`
51//! (PowerShell), and an extensionless shell script (Git Bash /
52//! MSYS2). This is the same approach pnpm and npm use via
53//! `cmd-shim`, and it avoids the need for Developer Mode or admin
54//! rights entirely.
55
56use std::ffi::OsString;
57use std::io::{self, Read};
58use std::path::{Component, Path, PathBuf};
59
60/// Create a directory link from `link` to `target`.
61///
62/// - Unix: a plain symlink (relative or absolute target OK).
63/// - Windows: an NTFS junction (relative targets are resolved to
64///   absolute against `link`'s parent first).
65pub 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/// Options controlling the shape of a generated bin entry.
118///
119/// `Default` preserves the pre-settings behavior: POSIX symlink,
120/// Windows shim without `NODE_PATH`.
121#[derive(Debug, Clone, Copy, Default)]
122pub struct BinShimOptions<'a> {
123    /// Export `NODE_PATH` in shell / cmd / PowerShell shims so the
124    /// shimmed binary can resolve transitives that live outside the
125    /// directory tree walked by Node from the cwd. Has no effect when
126    /// the final entry is a POSIX symlink (symlinks can't export env
127    /// vars). When `hidden_modules_dir` is set, the shim's NODE_PATH
128    /// becomes a colon/semicolon-separated list of: the bin's
129    /// top-level `node_modules`, then the hidden modules dir at
130    /// `<virtual_store>/node_modules`. Otherwise it's just the
131    /// top-level `node_modules`.
132    pub extend_node_path: bool,
133    /// POSIX-only. `None` → platform default (symlink). `Some(true)` is
134    /// equivalent. `Some(false)` writes a shell-script shim instead, so
135    /// `extend_node_path` can actually inject `NODE_PATH`. Ignored on
136    /// Windows — shims are always used there.
137    pub prefer_symlinked_executables: Option<bool>,
138    /// Absolute path to the virtual store's hidden modules dir
139    /// (`<project>/node_modules/.aube/node_modules`). When set and
140    /// `extend_node_path=true`, the generated shim includes it in
141    /// `NODE_PATH` so transitives hoisted there resolve when the
142    /// shimmed binary asks Node for them — pnpm's `.pnpm/node_modules`
143    /// behavior. Independent of `bin_dir` so workspace-member bin
144    /// shims (whose `bin_dir` is nowhere near `.aube/`) get the same
145    /// resolution shape as the root importer's `.bin/`.
146    pub hidden_modules_dir: Option<&'a Path>,
147}
148
149/// Target and environment recovered from an aube-generated bin wrapper.
150///
151/// Paths are resolved against the wrapper's parent. `node_path` is an
152/// OS-native path list ready to pass to [`std::process::Command::env`].
153#[derive(Debug, PartialEq, Eq)]
154pub struct ResolvedBinShim {
155    pub target: PathBuf,
156    pub node_path: Option<OsString>,
157}
158
159/// Create bin shims for a package binary.
160///
161/// - Unix (default / `prefer_symlinked_executables != Some(false)`):
162///   a symlink from `bin_dir/<name>` to `target`, with the target
163///   chmod'd to 755.
164/// - Unix (`prefer_symlinked_executables = Some(false)`): a shell
165///   wrapper that `exec`s `target` directly or via its detected
166///   interpreter. If `extend_node_path` is set, the wrapper exports
167///   `NODE_PATH` first.
168/// - Windows: three wrapper scripts in `bin_dir`:
169///   - `<name>.cmd` — batch wrapper for cmd.exe
170///   - `<name>.ps1` — PowerShell wrapper
171///   - `<name>` (no extension) — shell wrapper for Git Bash / MSYS2
172///
173///   `extend_node_path` sets `NODE_PATH` near the top of each wrapper.
174///
175/// The `target` path should be absolute; generated wrappers embed a
176/// path relative to the wrapper's own parent directory so the tree
177/// stays relocatable even for scoped bin names under `.bin/@scope/`.
178pub 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        // Clear stale shims or legacy symlinks. Old aube versions wrote
220        // these as junctions. `remove_file` fails on a junction, so
221        // fall through to `remove_dir` to avoid leaving a stale entry
222        // that later `fs::write` cannot overwrite (ERROR_ALREADY_EXISTS).
223        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        // Tolerate `AlreadyExists` from the parent mkdir. Rayon-parallel
229        // callers race on the same `.bin/`. Windows also returns os 183
230        // spuriously when the dir sits behind a junction, even when the
231        // dir is visible.
232        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        // cmd.exe wants backslash paths; PowerShell + the Git-Bash `.sh`
244        // wrapper want forward-slash paths. NODE_PATH itself is parsed by
245        // Node.js, which on Windows always splits on `;` (`path.delimiter`)
246        // regardless of which shell launched it, so every Windows shim uses
247        // `;`. Mixing `:` here would make Node treat the multi-entry value
248        // as one invalid path and silently drop the hidden-modules entry.
249        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
280/// Reject bin-entry keys that would let a hostile `package.json`
281/// aim a shim outside its `.bin/` directory. npm/pnpm had the same
282/// class of bug (GHSA-p4v2-fp7g-q4rg / CVE-2024-27298). Accepts a
283/// bare filename, or exactly one scope-prefix segment `@scope/name`
284/// to match pnpm's `.bin/@scope/` layout.
285pub 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
312/// Reject relative bin target paths that escape the package root,
313/// are absolute, or carry Windows drive / UNC prefixes.
314pub 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    // Shell-metachar reject: the generated `.cmd` / `.ps1` / sh shims
322    // splice this string into double-quoted command lines that PowerShell
323    // (`$(...)`, `` ` ``, `$env:`) and cmd.exe (`%VAR%`) re-evaluate
324    // before invocation. npm / pnpm / yarn all reject these on `bin`
325    // targets too — no real package ships such a path.
326    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    // Windows-only extras: `:` opens an NTFS alternate data stream
387    // and separates drive letters, reserved device names map to
388    // physical devices, and trailing dot / space gets stripped by
389    // the filesystem so `con.` collides with `con`. npm, pnpm, and
390    // bun all accept these on POSIX so this reject must stay
391    // platform-gated — otherwise packages with a legitimate `:` in
392    // their bin key (a handful of cordova / ionic tools) stop
393    // linking on Linux and macOS.
394    #[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
423/// Remove bin shims previously created by [`create_bin_shim`].
424///
425/// On Unix, removes the symlink. On Windows, removes the `.cmd`,
426/// `.ps1`, and extensionless wrapper scripts.
427pub 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/// Atomic shim write. Stale dir or junction at `dst` makes `fs::write`
445/// fail with `ERROR_ALREADY_EXISTS` (os 183). Try direct write first.
446/// On that error, wipe whatever blocks the path (file, dir, junction)
447/// and retry once. Fast path stays allocation-free.
448#[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            // `remove_dir` (non-recursive) clears an empty dir or a
454            // junction. A populated dir at a shim path is a real
455            // conflict. Let the retry write surface that error instead
456            // of silently wiping the subtree with `remove_dir_all`.
457            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/// Paths of every Windows shim file `create_bin_shim` writes for
466/// `name`: the extensionless wrapper, the `.cmd` stub, and the
467/// `.ps1` stub. Index 0 is the extensionless wrapper — callers that
468/// already unlinked it (the unix-first branch of `remove_bin_shim`)
469/// can skip it with `.into_iter().skip(1)`.
470#[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
479/// Compute the relative path from `base_dir` to `target`, using
480/// forward slashes.
481///
482/// On Windows, strip any `\\?\` verbatim drive prefix from both inputs
483/// before diffing. Mixing a plain `C:\…` base with a verbatim
484/// `\\?\C:\…` target makes `pathdiff` treat the two `Component::Prefix`
485/// values as distinct (`Disk` != `VerbatimDisk`) and fall back to
486/// returning the raw absolute target. The raw target then gets
487/// interpolated into the `.cmd` shim as `"%~dp0\\\\?\\<target>"`, which
488/// `cmd.exe` + Node surface as the classic `Cannot find module
489/// '<bin>\\?\\<target>'` error. Stripping on both sides keeps the
490/// prefix components equal so `pathdiff` produces the expected
491/// `..\\…` form.
492fn 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
501/// Build the value the bin shim assigns to `NODE_PATH`. Always starts
502/// with the `node_modules/` that holds the `.bin/` directory itself
503/// (recovers Node's `cwd` walk-up from a shim invoked outside its
504/// project). When the caller supplies `hidden_modules_dir`, that path
505/// is appended so transitives hoisted to `<virtual_store>/node_modules`
506/// — the only place auto-installed peers like `typescript` live for an
507/// isolated install — resolve too. Matches the load-bearing entries of
508/// pnpm's own NODE_PATH (the bin's `node_modules`, then the hidden
509/// `.pnpm/node_modules`).
510///
511/// `path_sep` is `/` on POSIX/PowerShell/Git-Bash and `\` for cmd.exe;
512/// `list_sep` is `:` on POSIX, `;` on cmd.exe. Each entry is prefixed
513/// with `$basedir/` (or `%~dp0` for cmd via the caller's prefix —
514/// cmd's `%~dp0` already ends with a backslash so no extra path-sep is
515/// emitted between prefix and entry).
516fn 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        // cmd: `%~dp0` already ends in a backslash, so don't emit one.
525        ("%~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
555/// Read the shebang line of `target` to determine how a bin shim
556/// launches it. Known script extensions retain their interpreter
557/// fallback. Existing targets with native executable magic are launched
558/// directly, as are `.exe` targets that a postinstall may replace with a
559/// host-native executable after the shim has already been written.
560///
561/// Only reads the first 256 bytes — enough for any realistic shebang
562/// line without pulling large bundled scripts into memory.
563fn 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        // Strip `/usr/bin/env ` prefix (with optional -S flag)
577        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            // Strip leading env var assignments (KEY=val)
581            rest.split_whitespace()
582                .find(|s| !s.contains('='))
583                .unwrap_or("node")
584        } else {
585            // Absolute path like /usr/bin/node → take basename
586            line.split_whitespace()
587                .next()
588                .and_then(|p| p.rsplit('/').next())
589                .unwrap_or("node")
590        };
591        // `prog` is later interpolated verbatim into `.cmd` / `.ps1`
592        // / `.sh` shim templates. Any byte outside a conservative
593        // identifier class would let an attacker-published bin
594        // script (whose shebang we are parsing right here) break
595        // out of the shim's quoted strings and run arbitrary cmd
596        // commands on every shim invocation. Reject anything that
597        // is not shell-safe on every supported platform and fall
598        // through to the extension-based default.
599        if is_safe_prog(prog) {
600            return BinLaunch::Interpreter(prog.to_string());
601        }
602        // Unsafe shebang. Log it rather than rewriting silently so
603        // the fall-through is visible in install output. Both path
604        // and prog go through Debug formatting so any terminal
605        // escape sequences smuggled in either one are printed as
606        // escaped literals rather than acted on by the terminal.
607        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
616/// The character class `prog` is allowed to draw from. Derived from
617/// the set of tokens that appear as real npm package interpreter
618/// shebangs (`node`, `bash`, `sh`, `python3`, `python3.11`, `ruby`,
619/// `deno`, `bun`) — all ASCII alphanumerics plus `.`, `_`, `+`, `-`.
620/// Rejects `"`, `&`, `|`, `<`, `>`, `^`, `%`, NUL, whitespace, and
621/// every other cmd.exe / PowerShell / sh metacharacter.
622fn is_safe_prog(prog: &str) -> bool {
623    if prog.is_empty() || prog.len() > 64 {
624        return false;
625    }
626    // The first character must be alphanumeric. A leading `-`, `.`,
627    // `_`, or `+` is rejected even though those characters are safe
628    // in the interior, because no real interpreter name starts with
629    // one and a leading `-` would otherwise produce a shim that
630    // looks like a CLI flag when inspected.
631    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
669/// Run-time substitute for any `prog` that reaches a shim generator
670/// without passing `is_safe_prog`. Every caller in this crate goes
671/// through `detect_bin_launch` and never trips this branch, but a
672/// future caller that bypasses that path would otherwise produce a
673/// shim with attacker-controlled bytes. A `tracing::error!` is emitted
674/// so the regression is visible in release builds too, not only in
675/// debug.
676fn 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
831/// Marker the POSIX shim writer stamps into every generated file so
832/// [`parse_posix_shim_target`] can unambiguously identify our shims and
833/// recover the `$basedir`-relative target path on uninstall. Any format
834/// change here must bump the version suffix so older shims stop being
835/// recognized (forcing a reinstall) rather than being silently
836/// misparsed.
837pub const POSIX_SHIM_MARKER_PREFIX: &str = "# aube-bin-shim v2 target=";
838
839/// Resolve the invoked shim through absolute and relative symlink hops before
840/// deriving `$basedir`. The 40-hop cap matches the Linux kernel's `ELOOP`
841/// limit and prevents a user-created symlink cycle from hanging execution.
842const 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/// POSIX shell-script shim used when `prefer_symlinked_executables=false`
855/// (so `extend_node_path` can actually inject `NODE_PATH`). Mirrors the
856/// Windows `generate_sh_shim` output without the cygpath dance, with a
857/// stamped [`POSIX_SHIM_MARKER_PREFIX`] comment at the top so
858/// `unlink_bins` can locate the embedded target without having to parse
859/// the shell body.
860#[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
894/// Recover the `$basedir`-relative target embedded by
895/// [`generate_posix_shim`]. Returns `None` for any content that lacks
896/// the [`POSIX_SHIM_MARKER_PREFIX`] marker — including shims written by
897/// other tools and older aube versions if the marker is ever bumped.
898/// Lives in this module so the format contract stays in one file with
899/// its writer.
900pub 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
909/// Maximum wrapper size accepted by [`resolve_bin_shim`]. Generated wrappers
910/// are normally under 2 KiB; the larger cap accommodates long Windows paths
911/// without reading arbitrary foreign files into memory.
912const MAX_BIN_SHIM_BYTES: u64 = 64 * 1024;
913
914#[derive(Clone, Copy)]
915enum BinShimStyle {
916    Posix,
917    Cmd,
918}
919
920/// Decode an aube-generated wrapper without executing it.
921///
922/// Only regular files at most 64 KiB are inspected. POSIX wrappers must carry
923/// aube's versioned marker; cmd wrappers must match the generated `@SETLOCAL`
924/// and local-interpreter branch shape. Symlinks and unrecognized wrappers
925/// return `Ok(None)`.
926pub 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    // Windows extensionless shims use a semicolon-delimited NODE_PATH even
1051    // though their shell syntax otherwise resembles the POSIX wrapper.
1052    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
1069/// Collapse `.` / `..` components without touching the filesystem.
1070/// Used on Windows to give `junction::create` an absolute target when
1071/// the caller computed a relative `../../foo` — `canonicalize` isn't
1072/// an option because it requires the target to already exist and
1073/// strips the UNC prefix the junction API is happy to accept.
1074/// Also exposed cross-platform so callers can resolve relative paths
1075/// stored in POSIX shims without tripping over macOS's `/var` →
1076/// `/private/var` symlink (canonicalize eagerly follows that symlink,
1077/// which throws off the `..` count in shim-embedded relative targets).
1078pub 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        // `std::fs::canonicalize` on Windows returns `\\?\C:\…`. If a
1321        // canonicalized `target` flows in next to a plain-drive
1322        // `base_dir`, `pathdiff` sees `Disk` vs `VerbatimDisk` prefix
1323        // components and falls back to the absolute target — which
1324        // then gets spliced into the `.cmd` shim as
1325        // `%~dp0\\?\<target>` and surfaces as Node's
1326        // `Cannot find module '<bin>\?\<target>'`.
1327        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        // `\\?\UNC\…` identifies a real network share and has no
1346        // non-verbatim equivalent — strip_verbatim must leave it
1347        // alone so the shim points at the share, not at a bogus
1348        // drive-rooted path.
1349        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        // Relative target, mimicking how the linker builds them.
1373        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        // All three files must exist
1396        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        // .cmd should reference node and the relative target
1401        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        // .ps1 should reference node
1406        let ps1 = std::fs::read_to_string(bin_dir.join("is-odd.ps1")).unwrap();
1407        assert!(ps1.contains("node$exe"));
1408
1409        // extensionless should be a shell script
1410        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        // First shim
1427        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        // Update script and re-shim
1431        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        // Content should be the same (same target path), but no error from overwrite
1436        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        // Target should be executable
1480        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        // Must be a regular file, not a symlink.
1576        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        // Marker comment has to land in the shim so `parse_posix_shim_target`
1582        // can round-trip the target on uninstall.
1583        assert!(content.contains(POSIX_SHIM_MARKER_PREFIX));
1584        // NODE_PATH should NOT be exported when extend_node_path=false.
1585        assert!(!content.contains("NODE_PATH"));
1586        // Must be marked executable.
1587        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        // The parser and generator live together so this loop-back
1677        // guards the format contract end-to-end: anything that
1678        // changes the marker on one side breaks this test.
1679        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        // Arbitrary shell content without our marker must not match —
1713        // otherwise `unlink_bins` would start removing bins owned by
1714        // other tooling.
1715        assert!(parse_posix_shim_target("#!/bin/sh\necho hi\n").is_none());
1716        // A stray `exec` line with `$basedir/...` isn't enough: the
1717        // dedicated marker is the only anchor.
1718        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        // The regression this guards: without the hidden-modules entry,
1837        // tools like `astro check` invoked from a shimmed bin can't see
1838        // auto-installed peers (e.g. `typescript`) that aube hoists to
1839        // `<project>/node_modules/.aube/node_modules/`. The single
1840        // `$basedir/..` entry only covers the top-level `node_modules/`,
1841        // which holds direct deps but never transitives.
1842        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        // extend_node_path is meaningless when the output is a bare
1881        // symlink — no file to inject an env export into. The symlink
1882        // still gets created, and the test only confirms that the
1883        // Some(true) / None paths behave identically.
1884        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        // Regression: Node.js on Windows splits NODE_PATH on `;`
1943        // (`path.delimiter`) regardless of which shell launched it.
1944        // The ps1 / .sh wrappers use forward-slash paths but must
1945        // still join with `;`, or Node treats the multi-entry value
1946        // as one invalid path and drops the hidden-modules entry.
1947        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    // ---------------------------------------------------------------
2014    // Shebang sanitization (defense against shim-injection RCE).
2015    //
2016    // `detect_bin_launch` feeds `prog` verbatim into the cmd / ps1 /
2017    // sh shim templates via `format!`. An attacker-published bin
2018    // script whose shebang carries cmd.exe metacharacters would break
2019    // out of the quoted path in the generated `.cmd` and execute
2020    // arbitrary commands on every shim invocation. `is_safe_prog`
2021    // must block every such case and fall through to the
2022    // extension-based default.
2023    // ---------------------------------------------------------------
2024
2025    #[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        // Non-ASCII Unicode identifiers are valid in some systems but
2064        // never appear in legitimate shebangs and are a signal of an
2065        // attack attempting to smuggle lookalike glyphs past naive
2066        // string compares. Reject on principle.
2067        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        // No real interpreter name starts with `-`, `.`, `_`, or
2092        // `+`, and a leading `-` would make the resulting shim
2093        // resemble a CLI flag. Reject these even though the same
2094        // characters are fine in the interior.
2095        assert!(!is_safe_prog("-node"));
2096        assert!(!is_safe_prog(".node"));
2097        assert!(!is_safe_prog("_node"));
2098        assert!(!is_safe_prog("+node"));
2099        // Interior punctuation still allowed.
2100        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        // The classic payload. Without sanitization the generated
2109        // .cmd shim would contain `"%~dp0\node"&calc&".exe"` which
2110        // cmd.exe parses as an `&calc&` command sequence.
2111        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        // Unsafe shebang plus a `.sh` extension falls back to `sh`,
2145        // not `node`, because the extension-based default is chosen
2146        // after the sanitization rejection.
2147        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        // Legitimate case: `python3.11` must still work.
2159        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        // Anything past 64 chars falls back. No legitimate
2171        // interpreter name approaches this length.
2172        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    // ---------------------------------------------------------------
2184    // Production safety net. Even if a future caller hands an unsafe
2185    // string straight to a shim generator without going through
2186    // `detect_bin_launch`, `safe_prog` must substitute a harmless
2187    // default rather than splice attacker bytes into the template.
2188    // Runs in both debug and release, unlike `debug_assert!`.
2189    // ---------------------------------------------------------------
2190
2191    #[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        // The core attack payload the shim templates would otherwise
2200        // interpolate verbatim. `safe_prog` must never return it.
2201        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        // Direct call bypassing `detect_bin_launch`. The generated
2211        // batch file must not contain the attacker's payload bytes.
2212        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        // Substituted with the safe default.
2226        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}