Skip to content
Merged
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
65 changes: 47 additions & 18 deletions src/install/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,27 +577,56 @@ impl RunCommand {

let argv0: &ZStr = bun_core::argv().get(0).unwrap_or(bun_core::zstr!("bun"));

// if we are already an absolute path, use that
// if the user started the application via a shebang, it's likely that the path is absolute already
let argv0_z: &ZStr = if argv0.as_bytes().first() == Some(&b'/') {
*optional_bun_path = argv0.as_bytes();
argv0
} else if optional_bun_path.is_empty() {
// otherwise, ask the OS for the absolute path
let self_path = bun_core::self_exe_path()?;
if !self_path.as_bytes().is_empty() {
*optional_bun_path = self_path.as_bytes();
self_path
} else {
argv0
}
} else {
// When argv[0] is
// not absolute and the caller pre-supplied a path, that path is the
// symlink target (NOT argv[0]).
// PREFER `self_exe_path()` OVER `argv[0]`: on a nested `--bun`, the
// OUTER bun prepends `BUN_NODE_DIR` to `PATH` and the INNER bun is
// execve'd with `argv[0] = <BUN_NODE_DIR>/bun` — exactly the shim
// we're about to (re)write. Using that as the symlink target
// produces `<BUN_NODE_DIR>/bun -> <BUN_NODE_DIR>/bun` (self-loop),
// and the next `/usr/bin/env node` bails with ELOOP "Too many
// levels of symbolic links" (#30711). `self_exe_path()` readlinks
// `/proc/self/exe` (Linux) / canonicalizes `_NSGetExecutablePath`
// (macOS), so it always resolves to the REAL bun regardless of
// how the process was invoked. It's memoized via `Once`, so the
// cost is paid once per process.
let argv0_z: &ZStr = if !optional_bun_path.is_empty() {
// When the caller pre-supplied a path, that path is the symlink
// target.
// SAFETY: callers pass a slice borrowed from a `ZStr` (argv[0] /
// self_exe_path / static literal), so `ptr[len] == 0` holds.
unsafe { ZStr::from_raw(optional_bun_path.as_ptr(), optional_bun_path.len()) }
} else {
// Ask the OS for the real absolute path first. Fall back to an
// absolute `argv[0]` only if that fails — never trust a bare
// `argv[0]` as the target here, because on nested `--bun` the
// inner process's `argv[0]` IS `<BUN_NODE_DIR>/bun`.
match bun_core::self_exe_path() {
Ok(self_path) if !self_path.as_bytes().is_empty() => {
*optional_bun_path = self_path.as_bytes();
self_path
}
result => {
let argv0_bytes = argv0.as_bytes();
if argv0_bytes.starts_with(Self::BUN_NODE_DIR.as_bytes()) {
// `self_exe_path()` failed and `argv[0]` is the shim
// under `BUN_NODE_DIR` (nested `--bun`). Using it as
// the target would recreate the #30711 self-loop; the
// OUTER bun already planted working shims and PATH, so
// leave them untouched.
return Ok(());
}
if argv0_bytes.first() == Some(&b'/') {
*optional_bun_path = argv0_bytes;
argv0
} else {
// No usable target — propagate the OS error when we
Comment thread
robobun marked this conversation as resolved.
// have one, otherwise leave PATH unmodified.
return match result {
Err(e) => Err(e),
Ok(_) => Ok(()),
};
}
}
}
};

#[cfg(bun_debug)]
Expand Down
51 changes: 51 additions & 0 deletions test/cli/install/bun-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1068,4 +1068,55 @@ describe.concurrent("bun run", () => {
expect(exitCode).toBe(0);
},
);

// https://github.com/oven-sh/bun/issues/30711 — nested `--bun` used to rewrite
// the BUN_NODE_DIR/{bun,node} shim to point at ITSELF. After the OUTER `--bun`
// prepends BUN_NODE_DIR to PATH, the INNER bun's argv[0] (PATH-resolved) is
// `<BUN_NODE_DIR>/bun` — exactly the shim it was about to rewrite. The symlink
// becomes self-referencing and `/usr/bin/env node` fails with ELOOP
// ("Too many levels of symbolic links").
it.skipIf(isWindows)("nested --bun does not create a self-referencing node/bun shim", async () => {
// Reproduce the reporter's exact invocation: `bun run --bun bun run --bun <binScript>`
// where:
// - the literal "bun" must be resolved via PATH so the inner process's
// argv[0] ends up being the shim path (this is what feeds the self-loop
// into the symlink target)
// - the final script must be a `node_modules/.bin/<name>` shim script
// that shebang's `/usr/bin/env node` — that's where ELOOP actually
// surfaces when the shim is broken
using dir = tempDir("bun-run-nested-bun-shim", {
"package.json": JSON.stringify({ name: "nested-bun-shim" }),
"node_modules/fake-pkg/bin.js": "#!/usr/bin/env node\nconsole.log('nested --bun ok');\n",
});

const dirStr = String(dir);
chmodSync(join(dirStr, "node_modules/fake-pkg/bin.js"), 0o755);

// node_modules/.bin/fake-pkg → ../fake-pkg/bin.js
const binDir = join(dirStr, "node_modules/.bin");
await $`mkdir -p ${binDir} && ln -sf ../fake-pkg/bin.js ${binDir}/fake-pkg`.quiet();

// Plant a `bun` symlink on PATH so the outer `--bun bun` resolves via
// PATH-lookup (not as an absolute argv[0]).
const pathBinDir = join(dirStr, "path-bin");
await $`mkdir -p ${pathBinDir} && ln -sf ${bunExe()} ${pathBinDir}/bun`.quiet();

await using proc = Bun.spawn({
cmd: [bunExe(), "run", "--bun", "bun", "run", "--bun", "fake-pkg"],
cwd: dirStr,
env: { ...bunEnv, PATH: `${pathBinDir}:${bunEnv.PATH ?? process.env.PATH}` },
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// Buggy versions emit "env: 'node': Too many levels of symbolic links"
// and exit 126; the fixed version runs the script cleanly.
expect({ stderr, stdout, exitCode }).toEqual({
stderr: expect.not.stringContaining("Too many levels of symbolic links"),
stdout: expect.stringContaining("nested --bun ok"),
exitCode: 0,
});
});
});
Loading