Skip to content

node:fs: ignore trailing slashes in realpath and realpathSync - #32920

Open
robobun wants to merge 1 commit into
mainfrom
farm/ef0f3165/fix-realpath-trailing-slash
Open

node:fs: ignore trailing slashes in realpath and realpathSync#32920
robobun wants to merge 1 commit into
mainfrom
farm/ef0f3165/fix-realpath-trailing-slash

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

What

fs.realpathSync("<regular file>/") (trailing slash) throws on Bun where Node resolves it:

import * as fs from "node:fs";
import * as os from "node:os";
const dir = fs.mkdtempSync(os.tmpdir() + "/fsrp-");
fs.writeFileSync(dir + "/f", "hello");
console.log(fs.realpathSync(dir + "/f/"));

Node v26.3.0 prints the file's real path. Bun throws:

ENOTDIR: not a directory, lstat '/tmp/fsrp-lsWZ77/f/'
 syscall: "lstat",
   errno: -20,
    code: "ENOTDIR"

Node's JS realpath/realpathSync implementation walks path components, so trailing separators on a regular file are a no-op. Only realpath.native/realpathSync.native, which call realpath(3), reject it with ENOTDIR. Bun made the non-native variant behave like the native one.

Cause

On POSIX, realpath_inner (src/runtime/node/node_fs.rs) implements both variants as open() + getFdPath(). open(2) on file/ fails with ENOTDIR because a trailing slash requires the last component to be a directory, so the native syscall's semantics leaked into the emulated variant. Before this change the two POSIX variants were byte-for-byte identical and only differed in the syscall tag attached to the error.

Fix

Strip trailing separators from the joined path before open() when the variant is Emulated. realpath.native and realpathSync.native are unchanged and still report ENOTDIR.

Scope

The change is POSIX only (#[cfg(not(windows))]). On Windows, fs.realpathSync and fs.realpath never reach realpath_inner: src/js/node/fs.ts dispatches both to a JS port of Node's component-walking implementation when process.platform === "win32" (lines 658 and 766), so trailing separators on that platform are handled there, not in this Rust function.

One caller of the Emulated variant is fs.promises.realpath, on every platform. That is a pre-existing mis-routing: Node's fsPromises.realpath uses the same semantics as realpath.native() and rejects this input with ENOTDIR and syscall: "realpath", while Bun wires promises.realpath to the non-native variant (today its errors carry syscall: "lstat" for every failure, not just this one). Because of that routing, this PR also makes fs.promises.realpath("<file>/") resolve on POSIX where Node rejects. Re-routing it is a one-line but cross-platform change with its own blast radius (it changes the error syscall tag everywhere, which Windows arm of realpath_inner promises.realpath runs through, and the variant internal/fs/glob.ts uses internally), so it is tracked separately in #32963 rather than bundled here. Once that lands, promises.realpath is on the native variant and its trailing-slash behavior matches Node with no further change to this code.

The new test is gated it.if(isPosix), matching the neighboring realpath tests in fs.test.ts.

Verification

New test in test/js/node/fs/fs.test.ts covers file/, file//, a symlink with a trailing slash, a Buffer path, a directory with a trailing slash, /, the fs.realpath callback form, and asserts realpathSync.native still throws ENOTDIR.

  • USE_SYSTEM_BUN=1 bun test test/js/node/fs/fs.test.ts -t "trailing slash" fails with the ENOTDIR above.
  • bun bd test test/js/node/fs/fs.test.ts -t realpath: 8 pass, 0 fail.
  • Node's own test-fs-realpath.js, test-fs-realpath-native.js, test-fs-realpath-buffer-encoding.js, and test-fs-realpath-pipe.js all pass.

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

@coderabbitai

coderabbitai Bot commented Jun 27, 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: 18 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: 8ea1bde4-2376-47cf-b731-cef148d01359

📥 Commits

Reviewing files that changed from the base of the PR and between 0170259 and 515a9af.

📒 Files selected for processing (2)
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/fs.test.ts

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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:07 PM PT - Jul 17th, 2026

@robobun, your commit 515a9af has some failures in Build #75108 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 32920

That installs a local version of the PR into your bun-32920 executable, so you can run:

bun-32920 --bun

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. "ENOENT: No such file or directory" on fs.realpathSync(tmpdir()) #8681 - fs.realpathSync(tmpdir()) throws ENOENT when the result has a trailing slash appended; this PR's fix to strip trailing slashes in the emulated realpath variant directly addresses that scenario

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #8681

🤖 Generated with Claude Code

Comment thread src/runtime/node/node_fs.rs Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Not adding Fixes #8681: that issue is unrelated. It reports ENOENT from syscall: "readlink" on realpathSync(tmpdir()) with no trailing slash, against Bun 1.0.26, whose realpath implementation was readlink based and has since been replaced. This PR is specifically about ENOTDIR on a trailing separator after a regular file in the current open() + getFdPath() implementation.

Comment thread src/runtime/node/node_fs.rs Outdated
@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Updated in 515a9af: switched the trailing-separator strip to strings::without_trailing_slash per review, and rebased onto current main (resolved a small conflict with the encode_path_result refactor). All realpath tests and Node's test-fs-realpath*.js suite pass locally; the new test still fails on stock Bun with the ENOTDIR from the description.

Build 75108 never compiled or tested this commit. All 32 build-cpp/build-rust jobs across every platform were created at 03:41:59 UTC and bulk-expired at 05:05:56 without any agent ever accepting them (started_at is null on every one), which cascaded the 252 downstream test jobs to waiting_failed. Builds 75107 (created 6 minutes before) and 75109 (3 minutes after) both had their build jobs picked up by agents within 1-13 minutes and ran normally, so this was not queue saturation; BuildKite's dispatcher never offered this build's jobs to any agent.

A Rebuild of build 75108 from the Buildkite UI runs the same commit with no extra git noise. I already used my one empty ci: retrigger earlier on this PR, so I am not pushing another.

Earlier CI notes (builds 65782 and 65902, pre-rebase)
  • Build 65782: the only hard, non-retried failure was test/regression/issue/24850.test.ts on x64-asan. Its 2 tests passed; the process then crashed at teardown with StrongRef.cpp:12: member call on null pointer of type 'JSC::HandleSet', the known Strong-handle teardown bug (Release RuntimeState's JSC handles before tearing down the VM #31990 is the open fix). Every other failing file was in CI's own flaky bucket.
  • Build 65902: Buildkite infrastructure outage. Both darwin 26 aarch64 - test-bun shards exited before running a single test with buildkite-agent artifact download timed out after 120s, 67 scheduled jobs expired with no agent, and 158 downstream jobs cascaded. 225 of 287 jobs never executed.

Comment thread src/runtime/node/node_fs.rs Outdated
Node's JS realpath implementation strips trailing separators before
walking path components, so realpathSync("<file>/") resolves to the
file's real path. Only realpath.native, which calls realpath(3),
reports ENOTDIR. Bun's non-native realpath passed the path straight to
open(2), which treats a trailing slash as "must be a directory" and
fails with ENOTDIR.

Strip trailing separators from the joined path in realpath_inner when
the variant is Emulated. realpath.native is unchanged.
@robobun
robobun force-pushed the farm/ef0f3165/fix-realpath-trailing-slash branch from 91fe94a to 515a9af Compare July 18, 2026 03:41

@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.

LGTM — my earlier comments (Windows scope, promises.realpath trade-off) are addressed in the description and tracked in #32963, and the switch to strings::without_trailing_slash per review is in.

What was reviewed:

  • without_trailing_slash preserves / (loop floor is e > 1), so root and // don't collapse to empty; path_len only shrinks, so the NUL write stays in-bounds.
  • Confirmed fs.ts:669/777 routes Windows realpath/realpathSync to the JS component-walker, so the #[cfg(not(windows))] scoping is complete for the callback/sync forms.
  • The helper also strips trailing \\ on POSIX (valid filename char) — flagged by finders, ruled out by verifiers as not a practical concern; the maintainer explicitly requested the shared helper.
  • Test covers file/, file//, symlink, Buffer, dir, /, callback form, and asserts .native still throws ENOTDIR.
Extended reasoning...

Overview

Seven-line change in the #[cfg(not(windows))] arm of realpath_inner (src/runtime/node/node_fs.rs): when variant == RealpathVariant::Emulated, compute path_len from strings::without_trailing_slash(joined) instead of joined directly, so open() sees the path without trailing separators. Also removes the now-dead let _ = variant;. One new POSIX-gated test in test/js/node/fs/fs.test.ts.

Security risks

None. The change only shortens the path passed to open(2) by trimming trailing separators; without_trailing_slash has a floor of 1 so / is preserved, and path_len can only be ≤ the original joined.len(), so inbuf[path_len] = 0 writes within the already-populated region. No new untrusted-input parsing.

Level of scrutiny

Moderate — Node compat behavior in node:fs. The mechanism is straightforward (input trimming before a syscall), the variant gate keeps .native semantics unchanged, and the PR description exhaustively documents scope: Windows realpath/realpathSync never reach this Rust arm (verified in src/js/node/fs.ts:669,777), and the fs.promises.realpath mis-routing that this incidentally makes more permissive on POSIX is a pre-existing, independently-observable gap now tracked as #32963.

Other factors

I previously left two review comments here (Windows sibling arm; POSIX promises.realpath trade-off). Both were addressed via the Scope section and #32963, and the threads are resolved. dylan-conway then asked for the shared without_trailing_slash helper, which was applied in 515a9af. The bug-hunting system's finders raised the "backslash is a filename char on POSIX" concern about that helper three times; verifiers refuted it each time, and the switch was maintainer-requested. Test coverage is thorough (single/multiple trailing slashes, symlink, Buffer, directory, root, callback form, and the negative assertion that .native still throws ENOTDIR with syscall: 'realpath'), and the description records that the test fails on stock Bun and that Node's own test-fs-realpath* suite still passes.

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