From e449e856e5d865cd0284da09470676d9d9869421 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:59:41 +0000 Subject: [PATCH] bunx: resolve directories.bin relative to the package When a package has no "bin" field, bunx reads its "directories.bin" and takes the first file in that directory as the executable's name. The directory was opened relative to the project root (or the bunx cache root) instead of the package, so it was never found and bunx failed with "could not determine executable to run" for every such package. While here, skip the same values the bin linker refuses to link from (empty, absolute, or escaping the package), so bunx never takes a bin name from a directory outside the package. --- src/install/bin.rs | 2 +- src/runtime/cli/bunx_command.rs | 14 +++- test/cli/install/bunx-directories-bin.test.ts | 75 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 test/cli/install/bunx-directories-bin.test.ts diff --git a/src/install/bin.rs b/src/install/bin.rs index 7bb19bf33bbf..53421be647c8 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -755,7 +755,7 @@ fn normalized_bin_name(name: &[u8]) -> &[u8] { /// verbatim from package.json, so without this check a malicious package could /// point a bin link at (and chmod) an arbitrary file on disk (the bug class /// npm fixed as CVE-2019-16775). -pub(crate) fn bin_target_escapes_package_dir(target: &[u8]) -> bool { +pub fn bin_target_escapes_package_dir(target: &[u8]) -> bool { if path::is_absolute(target) { return true; } diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index 15e2b2287af6..63d237bf12dc 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -335,8 +335,18 @@ impl BunxCommand { if let Some(dirs) = expr.as_property(b"directories") { if let Some(bin_prop) = dirs.expr.as_property(b"bin") { - if let Some(dir_name) = bin_prop.expr.as_utf8_string_literal() { - let bin_dir = bun_sys::openat_a(dir_fd, dir_name, O::RDONLY | O::DIRECTORY, 0)?; + // Same values the bin linker refuses to link from (`bin.rs`, `Tag::Dir`). + if let Some(dir_name) = bin_prop.expr.as_utf8_string_literal().filter(|dir| { + !dir.is_empty() && !bun_install::bin::bin_target_escapes_package_dir(dir) + }) { + // `directories.bin` is relative to the package, not to `dir_fd`. + use bun_paths::platform::Auto; + let package_dir = + bun_paths::resolve_path::dirname::(subpath_z.as_bytes()); + let bin_dir_path = + bun_paths::resolve_path::join_z::(&[package_dir, dir_name]); + let bin_dir = + bun_sys::openat(dir_fd, bin_dir_path, O::RDONLY | O::DIRECTORY, 0)?; // Fd is non-owning Copy; guard it. let _close_bin_dir = bun_sys::CloseOnDrop::new(bin_dir); let mut iterator = bun_sys::dir_iterator::iterate(bin_dir); diff --git a/test/cli/install/bunx-directories-bin.test.ts b/test/cli/install/bunx-directories-bin.test.ts new file mode 100644 index 000000000000..fd2702b6d456 --- /dev/null +++ b/test/cli/install/bunx-directories-bin.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +async function run(cwd: string, args: string[], env: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: { ...bunEnv, ...env }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// The bins are shell scripts, which the bin links cannot run on Windows. +test.concurrent.skipIf(isWindows)("bunx runs the bin of a directories.bin package", async () => { + using dir = tempDir("bunx-directories-bin", { + "package.json": JSON.stringify({ name: "app", dependencies: { tool: "file:./tool" } }), + "tool/package.json": JSON.stringify({ name: "tool", version: "1.0.0", directories: { bin: "bins" } }), + "tool/bins/tool-cli": "#!/bin/sh\necho tool-cli ran\n", + "tool/bins/nested/not-a-bin": "", + }); + chmodSync(join(String(dir), "tool", "bins", "tool-cli"), 0o755); + const env = { BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }; + expect(await run(String(dir), ["install"], env)).toMatchObject({ exitCode: 0 }); + + // The bin is not named after the package, so bunx has to read the package's + // `directories.bin` (relative to the package, not to the project) to learn + // its name. --no-install: failing to do so must not fall through to + // installing the package from the registry. + expect(await run(String(dir), ["x", "--no-install", "tool"], env)).toEqual({ + stdout: "tool-cli ran\n", + stderr: "", + exitCode: 0, + }); +}); + +// `bun install` links nothing for these values; bunx must not take a bin name +// from the directory they point at either. `picked` is the entry bunx would +// find there (and then run from node_modules/.bin) if it did. +const rejected: [label: string, value: (dir: string) => string, picked: string][] = [ + ["a relative path that leaves the package", () => "../../outside", "planted"], + ["an absolute path", dir => join(dir, "outside"), "planted"], + ["an empty string (the package directory itself)", () => "", "package.json"], +]; + +for (const [label, value, picked] of rejected) { + test.concurrent.skipIf(isWindows)(`bunx ignores a directories.bin that is ${label}`, async () => { + using dir = tempDir("bunx-directories-bin-rejected", { + "package.json": JSON.stringify({ name: "app", dependencies: { tool: "file:./tool" } }), + "tool/package.json": "", + "outside/planted": "", + }); + // Written afterwards because the value may depend on the directory's path. + writeFileSync( + join(String(dir), "tool", "package.json"), + JSON.stringify({ name: "tool", version: "1.0.0", directories: { bin: value(String(dir)) } }), + ); + const env = { BUN_INSTALL_CACHE_DIR: join(String(dir), ".cache") }; + expect(await run(String(dir), ["install"], env)).toMatchObject({ exitCode: 0 }); + const binDir = join(String(dir), "node_modules", ".bin"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, picked), "#!/bin/sh\necho planted ran\n", { mode: 0o755 }); + + const { stdout, stderr, exitCode } = await run(String(dir), ["x", "--no-install", "tool"], env); + + expect(stdout).toBe(""); + expect(stderr).toContain("could not determine executable to run for package tool"); + expect(exitCode).toBe(1); + }); +}