Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/install/bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
14 changes: 12 additions & 2 deletions src/runtime/cli/bunx_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Auto>(subpath_z.as_bytes());
let bin_dir_path =
bun_paths::resolve_path::join_z::<Auto>(&[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);
Expand Down
75 changes: 75 additions & 0 deletions test/cli/install/bunx-directories-bin.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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);
});
}
Loading