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
6 changes: 5 additions & 1 deletion src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -913,7 +913,11 @@ pub mod command {
};

if is_bun_x(argv0) {
if let Some(next) = argv.get(1) {
// BUN_OPTIONS tokens are spliced in right after argv[0], so the
// "add"/"exec" the parent bunx spawned its install child with sits
// at `1 + bun_options_argc()` — checking argv[1] made the escape
// hatch below miss it and bunx re-spawn itself forever (#39377).
if let Some(next) = argv.get(1 + bun::bun_options_argc()) {
let next_bytes = next.as_bytes();
if next_bytes == b"add"
&& bun_core::env_var::feature_flag::BUN_INTERNAL_BUNX_INSTALL.get()
Expand Down
96 changes: 95 additions & 1 deletion test/cli/install/bunx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from "bun";
import { afterAll, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
import { mkdir, rm, writeFile } from "fs/promises";
import { bunEnv, bunExe, isWindows, readdirSorted, tmpdirSync } from "harness";
import { chmodSync, copyFileSync, readdirSync, symlinkSync } from "node:fs";
import { chmodSync, copyFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "os";
import { delimiter, join, resolve } from "path";
import { dummyAfterAll, dummyBeforeAll, dummyBeforeEach, dummyRegistry, getPort, setHandler } from "./dummy.registry";
Expand Down Expand Up @@ -511,6 +511,100 @@ it.concurrent("should handle postinstall scripts correctly with symlinked bunx",
expect(exited).toBe(0);
});

// Regression test for #39377: BUN_OPTIONS tokens spliced after argv[0] used to
// defeat the BUN_INTERNAL_BUNX_INSTALL escape hatch's positional check, so the
// install child re-entered bunx mode and re-spawned itself forever. Before the
// fix this test hangs instead of completing.
it.concurrent(
"bunx does not fork-bomb when BUN_OPTIONS is set",
async () => {
const { x_dir, env } = setup();
// The bug only triggers when argv[0] is "bunx": the `bun x` spelling
// dispatches through the generic flag-skipping path and is immune.
copyFileSync(bunExe(), join(x_dir, isWindows ? "bun.exe" : "bun"));
copyFileSync(bunExe(), join(x_dir, isWindows ? "bunx.exe" : "bunx"));

const subprocess = spawn({
cmd: ["bunx", "esbuild@latest", "--version"],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
cwd: x_dir,
stdout: "pipe",
stdin: "inherit",
stderr: "pipe",
env: {
...env,
BUN_OPTIONS: "--smol",
PATH: `${x_dir}${isWindows ? ";" : ":"}${env.PATH || ""}`,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

const [err, out, exited] = await Promise.all([
subprocess.stderr.text(),
subprocess.stdout.text(),
subprocess.exited,
]);

expect(err).not.toContain("error:");
expect(out.trim()).not.toContain(Bun.version);
expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(exited).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
1000 * 60 * 2,
);

// Companion to the end-to-end test above: exercises the escape hatch directly,
// so a regression fails as a bounded wrong-output assertion in under a second
// (no network, no process chain). Covers the exec arm and a two-token
// BUN_OPTIONS value the e2e path doesn't reach.
it.concurrent("BUN_OPTIONS does not break the internal bunx install escape hatch", async () => {
const { env } = setup();
// Dispatch keys off argv[0] ending in "bunx".
const bunxDir = tmpdirSync();
const bunxPath = join(bunxDir, isWindows ? "bunx.exe" : "bunx");
if (isWindows) copyFileSync(bunExe(), bunxPath);
else symlinkSync(bunExe(), bunxPath);

// If the escape hatch misses, BunxCommand resolves "add"/"exec" as the
// package to run and finds these on PATH, so the failure is bounded
// wrong output instead of an unbounded chain of installs.
const decoyDir = tmpdirSync();
for (const name of ["add", "exec"]) {
if (isWindows) {
writeFileSync(join(decoyDir, `${name}.cmd`), `@echo MISDISPATCHED_${name.toUpperCase()}\r\n`);
} else {
writeFileSync(join(decoyDir, name), `#!/bin/sh\necho MISDISPATCHED_${name.toUpperCase()}\n`, { mode: 0o755 });
}
}

// One injected token and two, so skipping a fixed count instead of
// bun_options_argc() still fails.
for (const bunOptions of ["--smol", "--smol --silent"]) {
const childEnv = {
...env,
BUN_OPTIONS: bunOptions,
BUN_INTERNAL_BUNX_INSTALL: "true",
PATH: `${decoyDir}${delimiter}${env.PATH || ""}`,
};

{
await using proc = spawn({ cmd: [bunxPath, "add", "--help"], env: childEnv, stdout: "pipe", stderr: "pipe" });
const [out, errOut, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(out).not.toContain("MISDISPATCHED_ADD");
expect(errOut).not.toContain("MISDISPATCHED_ADD");
expect(out).toContain("bun add");
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

{
await using proc = spawn({ cmd: [bunxPath, "exec", "echo hatch-ok"], env: childEnv, stdout: "pipe", stderr: "pipe" });
const [out, errOut, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(out).not.toContain("MISDISPATCHED_EXEC");
expect(errOut).not.toContain("MISDISPATCHED_EXEC");
expect(out.trim()).toBe("hatch-ok");
expect(exitCode).toBe(0);
}
}
});

// Pinned to 20: its engines are "^20.19.0 || ^22.12.0 || >=24.0.0", so the node-24
// requirement this test exercises holds no matter what Node.js version Bun reports.
// @latest tracks Angular's engines upward and breaks whenever they outrun us.
Expand Down