Skip to content

cli: refuse to start when the cwd is longer than PATH_MAX instead of using the executable's directory - #38363

Open
robobun wants to merge 1 commit into
mainfrom
farm/bb47d3be/cwd-too-long-no-exe-dir-fallback
Open

cli: refuse to start when the cwd is longer than PATH_MAX instead of using the executable's directory#38363
robobun wants to merge 1 commit into
mainfrom
farm/bb47d3be/cwd-too-long-no-exe-dir-fallback

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun x.cjs started from a working directory whose absolute path is longer than PATH_MAX (4096 bytes on Linux, 1024 on macOS) runs <directory containing the bun binary>/x.cjs and exits 0; the x.cjs in the real cwd is ignored. With -e, require("./mod.cjs"), path.resolve() and Bun.main all point at the binary's directory while fs.readFileSync("mod.cjs") reads the real cwd. No warning is printed.
  • Cause: bun_core::getcwd_or_exe_dir (src/bun_core/util.rs:4140) substituted the executable's directory on every getcwd failure, and Arguments::parse (src/runtime/cli/Arguments.rs:867) seeded top_level_dir for run/auto commands from it. The fallback exists for a cwd that was deleted (ENOENT), but getcwd also fails with ERANGE (glibc, macOS) or ENAMETOOLONG (musl) when the cwd's path does not fit in the 4096-byte buffer, and that directory still exists.
  • Node refuses to start in that situation (ERANGE: process.cwd failed with error result too large, uv_cwd, exit 1). Bun's non-runtime commands already refused too, but with error: An internal error occurred (ERANGE).

Fix

  • getcwd_or_exe_dir falls back to the executable's directory only for ENOENT and returns every other failure; getcwd_len now maps errno instead of collapsing everything to Unexpected. The [eval]/[stdin]/cron/feedback callers in run_command.rs propagate the error with ?.
  • Arguments::parse does the same for the startup cwd: ENOENT on a run/auto/node command keeps the exe-dir fallback (bun_core::self_exe_dir, the old fallback body split out); anything else, and a relative --cwd without a usable base, prints ERANGE: Numerical result out of range: Could not get the current working directory (getcwd) and exits 1. This is the same error path --cwd already uses for a failed chdir, so bun test/bun build get the readable message as well.
  • Correct because the substituted directory is never what the user's relative paths mean: a deleted cwd has no files for them to refer to (and Node boots there too, which test/js/node/test/parallel/test-cwd-enoent*.js pin), while an over-long cwd does, so the only safe answer is to refuse.
  • Verified with test/cli/run/run_command.test.ts: five bun ... invocations (-e, x.cjs, run x.cjs, --cwd . x.cjs, test) from a cwd longer than PATH_MAX must print that error and exit 1; they fail on the current release build (-e exits 0 printing the exe dir, x.cjs and run x.cjs print Module not found "x.cjs", --cwd . and test print the internal-error line) and pass with this change. A sixth test pins that bun -e still boots from a deleted cwd and process.cwd() throws ENOENT there; it passes before and after.
  • Also ran the four test-cwd-enoent*.js node tests, test/cli/run/run-eval.test.ts and test/cli/run/as-node.test.ts against the debug build; all pass. The original repro (bun hardlinked next to a planted x.cjs, cwd of 4300 bytes) now prints the error instead of running the planted file, and a 4000-byte cwd still runs the real file.

Background

  • PATH_MAX bounds the length of a path passed to a single syscall, not how deep a process can be: chdir one component at a time works indefinitely, but getcwd has to return the whole path and fails once it does not fit. Bun's PathBuffer is exactly PATH_MAX bytes, so such a cwd cannot be represented anywhere in the resolver, and startup is the only place to reject it.
  • top_level_dir is the directory the runtime resolves the entry point, relative require/import and path.resolve() against. It is captured once at startup; process.cwd() does a fresh getcwd on every call, which is why it reported ERANGE while everything else used the exe dir.
  • The exe-dir fallback mirrors Node's Environment::GetCwd, which substitutes the executable's directory when uv_cwd fails so that node -e, -r and the REPL still work after the shell's cwd is deleted; Node's own relative-script path fails regardless because path.resolve calls process.cwd().
Not changed here
  • bun install / bun pm / bunx do not go through this block; they already refuse an over-long cwd via FileSystem::init(None) with the generic An internal error occurred (ERANGE) line, unchanged.
  • A deleted cwd plus a relative script (cd dir; rmdir dir; bun x.cjs) still resolves against the executable's directory, as before: that is the ENOENT fallback working as designed. Node errors there instead; tightening that is a separate decision.
  • A cwd within a few bytes of PATH_MAX (4084..4095 on Linux) panics earlier in bunfig loading, and process.chdir() into a directory whose path is exactly PATH_MAX-1 bytes panics in set_process_cwd; both are tracked separately.
  • getcwd: report CurrentWorkingDirectoryUnlinked from a deleted cwd again #32357 (stale) maps the same ENOENT to a dedicated error variant for the generic root-error message; if it is revived, its mapping and the FileNotFound match here need to agree.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/run_command.test.ts

…ger than PATH_MAX

getcwd_or_exe_dir fell back to the directory containing the bun binary on
every getcwd failure. The fallback exists for a deleted cwd (ENOENT), where
Node also boots and lets process.cwd() throw later, but it also fired when
getcwd failed with ERANGE/ENAMETOOLONG because the cwd's path does not fit in
PATH_MAX. That directory exists, so `bun x.cjs` ran <dir of bun>/x.cjs and
relative requires, path.resolve() and Bun.main all pointed at the binary's
directory while fs calls still used the real cwd, with exit code 0.

The fallback now applies to ENOENT only; every other getcwd failure at
startup is reported as
"ERANGE: ...: Could not get the current working directory (getcwd)" with
exit code 1, for the runtime and for the other commands that already refused
with "An internal error occurred (ERANGE)". The [eval]/[stdin] callers in
run_command.rs propagate the error instead of picking up the exe dir.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 761fab1c-7f7f-46b0-8c9b-677b17180ceb

📥 Commits

Reviewing files that changed from the base of the PR and between f7ad274 and 00dafdc.

📒 Files selected for processing (4)
  • src/bun_core/util.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/run_command.rs
  • test/cli/run/run_command.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:05 AM PT - Aug 14th, 2026

@robobun, your commit 00dafdc is building: #95472

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; build #95472 is marked failed only because its single macOS test job (darwin 14 aarch64 - test-bun) never ran. It waited in the release-tier=previous arm64 queue from 04:55 UTC, expired three times (the pipeline's automatic retries) and was not picked up; that pool has 6 slots and roughly 135 to 150 builds queued all morning. Every other lane passed (175 jobs; the few listed as flaky passed on retry or when re-run alone and none of them touch this code). So Linux (glibc, musl, x64, aarch64, ASAN) has exercised the new tests; macOS has not. Not re-pushing, since a new build would join the back of the same queue; a retry of just that job keeps its original queue position, which I have asked about.

Reproduced on bun 1.4.0 and on a release build of main by hardlinking bun into a directory containing a planted x.cjs, then running bun x.cjs from a working directory whose absolute path is 4300 bytes long (built by chdir-ing one component at a time): the planted file ran and the process exited 0. At 4000 bytes the real ./x.cjs ran. bun -e in the same cwd returned the exe-dir module for require("./mod.cjs") while fs.readFileSync("mod.cjs") read the real cwd.

With this branch both print ERANGE: Numerical result out of range: Could not get the current working directory (getcwd) and exit 1; a deleted cwd still boots bun -e (the test-cwd-enoent*.js node tests pass against the debug build). Tests: test/cli/run/run_command.test.ts, five cases failing on the unfixed build plus one deleted-cwd guard.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes the CLI startup cwd-resolution path that every bun invocation goes through (narrowing the exe-dir fallback and adding a new hard-exit), a human look would still be worthwhile.

What was reviewed:

  • All six getcwd_or_exe_dir callers in run_command.rs now propagate with ?; Arguments::parse runs first and calls exit_without_cwd on ERANGE/ENAMETOOLONG, so those ? sites are gated in practice.
  • errno mapping in getcwd_len (glibc ERANGE, musl ENAMETOOLONG, ENOENT → FileNotFound) matches the getcwd_or_exe_dir fallback arm and the test regex.
  • ENOENT (deleted cwd) still falls through to self_exe_dir for run/auto/node commands only — pinned by the new bun -e deleted-cwd test.
  • Test mutates the runner's process.cwd() but restores it in finally around synchronous spawnSync, so no leak into other tests.
Extended reasoning...

Overview

The PR narrows bun_core::getcwd_or_exe_dir so it only substitutes the executable's directory when getcwd fails with ENOENT (deleted cwd), and returns every other failure to the caller. getcwd_len now maps errno to specific CrateError variants instead of collapsing everything to Unexpected. The exe-dir fallback body is split into a new self_exe_dir helper. Arguments::parse mirrors this: on bun_sys::getcwd failure, ENOENT + a runtime command still uses the exe-dir fallback; anything else prints <ERRNO>: ...: Could not get the current working directory (getcwd) via a new exit_without_cwd helper and exits 1. Six run_command.rs call sites add ? to propagate the now-fallible result. Tests spawn bun from a cwd deeper than PATH_MAX (via relative-path chdir) across five command variants, plus one test pinning that a deleted cwd still boots.

Security risks

None introduced; this closes a security-adjacent bug where bun could silently run x.cjs from the directory containing the bun binary instead of the user's actual (over-long) cwd. The change is fail-closed: previously-silent wrong-directory execution now becomes a hard error.

Level of scrutiny

Medium-high. The diff is small (~40 native lines) and mechanically simple, but Arguments::parse and the run-command entry path execute on every bun invocation. A regression here would affect startup broadly. The change is strictly a narrowing (fewer cases reach the exe-dir fallback; more cases exit with an error), so the failure mode is refusing to start rather than running the wrong thing — but it still warrants a human sign-off on a startup-path behavior change.

Other factors

  • The new tests use process.chdir() on the test-runner process (a process-global mutation), restored in finally around synchronous spawnSync; this file has no test.concurrent, so it should be hermetic, but it's the kind of pattern reviewers sometimes flag.
  • The ? propagation in run_command.rs returns a bun_core::CrateError (not the rich bun_sys::Error with syscall tag); in practice Arguments::parse has already exited by then, but if reached the message would be less specific than exit_without_cwd's.
  • The PR description explicitly scopes out bun install/bun pm/bunx (different code path), the near-PATH_MAX bunfig panic, and the deleted-cwd + relative-script case — all reasonable deferrals but worth a maintainer nod.
  • CI build #95472 was still running at review time.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re the review notes: the ? sites in run_command.rs are only reachable if the cwd stops fitting between Arguments::parse and boot (a rename of an ancestor directory in that window), so the less specific message there is accepted; the user-facing path is exit_without_cwd. The bun install / bun pm / bunx entry points and the deleted-cwd relative-script case are deliberately left as they are, as listed under "Not changed here" in the description.

CI: build #95472 has 175 of 179 jobs passed with none failed so far. The red mordant check is the advisory lint ratchet tripping on h2_frame_parser.rs:1892, a line added by #38044 on main, not on anything in this diff; it is being handled separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants