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
2 changes: 1 addition & 1 deletion src/js/node/fs.promises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ const exports = {
return _writeFile(fileHandleOrFdOrPath, ...args);
},
readlink: asyncWrap(fs.readlink, "readlink"),
realpath: asyncWrap(fs.realpath, "realpath"),
realpath: asyncWrap(fs.realpathNative, "realpath"),
rename: asyncWrap(fs.rename, "rename"),
stat: asyncWrap(fs.stat, "stat"),
symlink: asyncWrap(fs.symlink, "symlink"),
Expand Down
6 changes: 5 additions & 1 deletion src/node_path/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,11 @@ fn without_trailing_slash(s: &[u8]) -> &[u8] {
#[cfg(not(windows))]
#[inline]
fn without_trailing_slash(s: &[u8]) -> &[u8] {
strings::without_trailing_slash(s)
let mut end = s.len();
while end > 1 && s[end - 1] == CHAR_FORWARD_SLASH {
end -= 1;
}
&s[..end]
}

fn get_cwd_u8(buf: &mut [u8]) -> MaybeBuf<'_, u8> {
Expand Down
55 changes: 21 additions & 34 deletions src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,6 @@ pub(crate) use super::node_fs_binding::Binding;
use bun_jsc::JSPromiseStrong;

use super::dir_iterator as DirIterator;
#[cfg(not(windows))]
use bun_resolver::fs::FileSystem;

// On POSIX the libuv-backed code paths (`UVFSRequest`, `uv_fs_*`) are absent:
// `UVFSRequest` aliases `AsyncFSTask` and every `uv::*` reference is gated
Expand Down Expand Up @@ -7562,7 +7560,7 @@ impl NodeFS {
// For `fs.realpath`, Node.js uses `lstat`, exposing the native system call under
// `fs.realpath.native`. In Bun, the system call is the default, but the error
// code must be changed to make it seem like it is using lstat (tests expect this),
// in addition, some more subtle things depend on the variant.
// and the path goes through `path.resolve` first, as it does in Node.js.
pub(crate) fn realpath_inner(
&mut self,
args: &args::Realpath,
Expand Down Expand Up @@ -7595,17 +7593,8 @@ impl NodeFS {
..Default::default()
});
}
let mut buf = unsafe { bun_core::ffi::cstr(ptr) }.to_bytes();
if variant == RealpathVariant::Emulated {
// remove the trailing slash
//
// `buf` is an immutable view and every consumer below copies by
// length, so just shrink the slice — writing a NUL back through
// `ptr.cast_mut()` while `buf` is live would be Stacked-Borrows UB.
if buf.last() == Some(&b'\\') {
buf = &buf[..buf.len() - 1];
}
}
let buf = unsafe { bun_core::ffi::cstr(ptr) }.to_bytes();
let _ = variant;
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
if args.encoding == Encoding::Utf8 {
if let PathLike::String(s) = &args.path {
if strings::eql_long(s.slice(), buf, true) {
Expand All @@ -7620,27 +7609,26 @@ impl NodeFS {
{
let mut outbuf = bun_paths::path_buffer_pool::get();
let inbuf = &mut self.sync_error_buf;
// SAFETY: single-threaded init flag (resolver/fs.rs).
debug_assert!(
bun_resolver::fs::INSTANCE_LOADED.load(core::sync::atomic::Ordering::Relaxed)
);

let path_slice = args.path.slice();
// SAFETY: instance() returns the leaked singleton; INSTANCE_LOADED checked above.
let fs = FileSystem::get();
let parts = [fs.top_level_dir, path_slice];
let inbuf_len = inbuf.len();
let Some(joined) = fs.abs_buf_checked(&parts, &mut inbuf[..inbuf_len - 1]) else {
return Err(sys::Error {
errno: E::ENAMETOOLONG as _,
syscall: sys::Tag::realpath,
path: args.path.slice().into(),
..Default::default()
});
let path = match variant {
RealpathVariant::Native => args.path.slice_z(inbuf),

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.

🟣 pre-existing, not blocking: pre-existing: on POSIX, fs.watch, fs.watchFile and cpSync symlink copying still treat a backslash as a separator, so a file named a\b is watched or linked as a/b. They join the path with join_abs_string_buf::platform::Posix, whose Posix arm at src/paths/resolve_path.rs:1386 uses the loose normalizer, the mechanism this PR removes from realpath_inner. Fix: make the Posix normalizer split on '/' only, or route these joins through bun_node_path::resolve_posix_t as realpath_inner now does, which covers the 4 sites listed. Same pattern at 4 sites (node_fs_watcher.rs:1147, node_fs_stat_watcher.rs:822, node_fs.rs:8426, path_watcher.rs:606).
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user on Linux or macOS calls fs.watch("a\b") or fs.watchFile("a\b") on a file literally named a\b, or runs fs.cpSync(src, dest, { recursive: true }) over a relative symlink whose target contains a backslash. FSWatcher::init at src/runtime/node/node_fs_watcher.rs:1145-1150 and StatWatcher::init at src/runtime/node/node_fs_stat_watcher.rs:819-822 join the input onto the cached top_level_dir with Path::join_abs_string_buf[_checked]::platform::Auto (Auto = Posix on unix); cp_symlink at src/runtime/node/node_fs.rs:8426 does the same with platform::Posix. normalize_string_buf_t's Platform::Posix arm at resolve_path.rs:1386-1388 dispatches to normalize_string_loose_buf_t, which rewrites every '\' as '/'. So the watcher is installed on cwd/a/b (ENOENT or a different file) and the copied symlink points at .../a/b instead of .../a\b. This is the exact mechanism the PR fixes for fs.realpath but the sibling node:fs entry points keep it; the base branch fails the same way, so this is pre-existing. The new test at test/js/node/fs/fs.test.ts:3241 only exercises the five realpath entry points.

Verification: pre-existing. On Linux/macOS fs.watch("a\b") on a file named a\b watches a/b instead. All three sites join through the same normalizer the PR removed from realpath_inner: node_fs_watcher.rs:1147, node_fs_stat_watcher.rs:822, node_fs.rs:8426. normalize_string_buf_t routes Platform::Posix to normalize_string_loose_buf_t (resolve_path.rs:1386-1388). The base already fails the same way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed for two of the four, not changed here. With a file a\b and a decoy a/b, fs.watch("a\\b") and fs.watchFile("a\\b") fire for the decoy on Linux where Node fires for a\b. path_watcher.rs:606 calls join_z_buf_spill, which goes through normalize_string_node_t and does split on / only. cp_symlink is not reached from fs.cp* on Linux, where links and directories take the JS walker (tryNativeFastPathSync), and copying a link to t\x gives Node's result. The watchers are left for a separate change because the cause is the shared normalizer: making its Posix arm strict also changes manifest and tarball handling that tests pin as lenient (migrate.test.ts "packages\\pkg1", bun-create.test.ts "behind a \").

🤖 Addressed by Claude Code

RealpathVariant::Emulated => {
let parts = [args.path.slice()];
let len = bun_node_path::resolve_buf_len(false, &parts);
let mut bufs = vec![0u8; 2 * len];
let (buf, buf2) = bufs.split_at_mut(len);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let resolved = bun_node_path::resolve_posix_t(&parts, buf, buf2)?;
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
Comment on lines +7615 to +7620

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.

🟣 pre-existing, not blocking: pre-existing: a program that calls fs.realpath(relativePath, cb) and then process.chdir() can get the path resolved against the new cwd, or a torn cwd string, where Node resolves it synchronously against the cwd at call time. The Emulated arm at src/runtime/node/node_fs.rs:7620 runs resolve_posix_t on the work-pool thread, reading the cwd bytes that process.chdir rewrites in place. Fix: for the async Emulated flavor, capture the cwd (or run path.resolve) on the JS thread when the call is made, before the task is queued, or copy top_level_dir under the lock rather than handing out a slice into a buffer that chdir overwrites.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user calls fs.realpath("x", cb) (callback form, RealpathVariant::Emulated via realpath_cb at src/runtime/node/node_fs_binding.rs:409) and then process.chdir("other") on the same tick. The callback flavor runs NodeFS::dispatch with Flavor::Async on the work pool (src/runtime/node/node_fs.rs:1375), so realpath_inner reaches bun_node_path::resolve_posix_t at node_fs.rs:7620 on a pool thread. resolve_posix_t fetches the cwd through get_cwd_u8 at src/node_path/lib.rs:198-201, which copies top_level_dir(), a &'static [u8] pointing into fs.top_level_dir_buf. process.chdir on the JS thread calls set_process_cwd at src/jsc/VirtualMachine.rs:5822-5837, which overwrites top_level_dir_buf in place and then publishes a new slice; a pool thread that already holds the old slice reads bytes while they are being rewritten, and one that reads after the publish resolves "x" against the new cwd. Node's fs.realpath runs pathModule.resolve(p) synchronously inside the call, so the later chdir cannot affect it. The base branch did the same read on the pool thread through FileSystem::abs_buf_checked, so this is pre-existing.

Verification: pre-existing. Trigger: a program calls fs.realpath(relativePath, cb) and then process.chdir() before the work-pool thread has run the task. The Emulated arm at node_fs.rs:7620 fetches the cwd on the pool thread; set_process_cwd rewrites fs.top_level_dir_buf in place at src/jsc/VirtualMachine.rs:5822-5835, so the task can read a torn cwd or resolve against the new cwd. The base branch did the same read on the same pool thread.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, and the same before this PR, so not changed here. Running fs.realpath("x", cb) then process.chdir() in the same tick 200 times on Linux: Node v26.7.0 resolves against the old cwd 200 times; Bun 1.4.3 uses the new cwd 51 times and once returns the cwd without x. main reads FileSystem::top_level_dir on the pool thread in the same place. fs.realpath.native is subject to the same timing in Node (8 of 200 used the new cwd). Taking the cwd on the JS thread touches how async node:fs arguments are captured, which is a separate change.

🤖 Addressed by Claude Code

if resolved.len() >= inbuf.len() {
return Err(sys::Error {
errno: E::ENAMETOOLONG as _,
syscall: sys::Tag::realpath,
path: args.path.slice().into(),
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
..Default::default()
});
}
paths::resolve_path::z(resolved, inbuf)
}
};
let path_len = joined.len();
inbuf[path_len] = 0;
let path = ZStr::from_buf(&inbuf[..], path_len);

#[cfg(any(target_os = "linux", target_os = "android"))]
let flags = sys::O::PATH; // O_PATH is faster
Expand All @@ -7658,7 +7646,6 @@ impl NodeFS {
Ok(buf_) => buf_,
};

let _ = variant;
if args.encoding == Encoding::Utf8 {
if let PathLike::String(s) = &args.path {
if strings::eql_long(s.slice(), buf, true) {
Expand Down
8 changes: 7 additions & 1 deletion src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7685,7 +7685,13 @@ pub fn get_fd_path<'a>(fd: Fd, out: &'a mut bun_paths::PathBuffer) -> Maybe<&'a
#[cfg(target_os = "macos")]
{
out.0.fill(0);
fcntl(fd, libc::F_GETPATH, out.0.as_mut_ptr() as isize)?;
if let Err(mut err) = fcntl(fd, libc::F_GETPATH, out.0.as_mut_ptr() as isize) {
// `F_GETPATH` reports a path longer than `MAXPATHLEN` as `ENOSPC`.
if err.get_errno() == E::ENOSPC {
err.errno = E::ENAMETOOLONG as _;
}
return Err(err);
}
// SAFETY: F_GETPATH writes a NUL-terminated string into `out`.
let len = unsafe { libc::strlen(out.0.as_ptr().cast()) };
return Ok(&mut out.0[..len]);
Expand Down
134 changes: 128 additions & 6 deletions test/js/node/fs/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3238,6 +3238,105 @@ it.if(isPosix)("realpathSync resolves root, regular files, and symlinks", () =>
expect(realpathSync(linkPath)).toBe(self);
});

it.if(isPosix)("realpath reads its path the way Node does", async () => {
using dir = tempDir("fs-realpath-path", {
"file": "",
"y": "",
"dir/y": "",
"dir/sub/x": "",
"a\\b": "",
"a/b": "",
"\\c": "",
"C:\\d": "",
"w\\d/z": "",
"e\\/z": "",
"e/z": "",
});
symlinkSync("dir/sub", join(String(dir), "link"));

// fs.realpath starts from path.resolve(path). fs.realpath.native and
// fs.promises.realpath leave the whole path to the system.
const cases: [path: string, resolved: string, native: string][] = [
["a\\b", "/a\\b", "/a\\b"],
["\\c", "/\\c", "/\\c"],
["C:\\d", "/C:\\d", "/C:\\d"],
["w\\d/z", "/w\\d/z", "/w\\d/z"],
["w\\d/../file", "/file", "/file"],
["a\\..\\file", "ENOENT lstat", "ENOENT realpath"],
["file/", "/file", "ENOTDIR realpath"],
["file/..", "", "ENOTDIR realpath"],
["nope/../file", "/file", "ENOENT realpath"],
["link/../y", "/y", "/dir/y"],
];

const script = `
import fs from "node:fs";
import { promisify } from "node:util";
const cwd = process.cwd();
const impls = [
fs.realpathSync,
promisify(fs.realpath),
fs.realpathSync.native,
promisify(fs.realpath.native),
fs.promises.realpath,
];
async function run(path) {
const results = [];
for (const impl of impls) {
try {
results.push((await impl(path)).slice(cwd.length));
} catch (err) {
results.push(err.code + " " + err.syscall);
}
}
return results;
}
const out = [];
for (const path of ${JSON.stringify(cases.map(([path]) => path))}) {
out.push(await run(path), await run(cwd + "/" + path), await run(Buffer.from(path)));
}
out.push(await run(""));
process.chdir("w\\\\d");
out.push(await run("."), await run("z"));
process.chdir("../e\\\\");
out.push(await run("."), await run("z"));
console.log(JSON.stringify(out));
`;

await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const row = (resolved: string, native: string) => [resolved, resolved, native, native, native];
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual([
...cases.flatMap(([, resolved, native]) => Array(3).fill(row(resolved, native))),
row("", "ENOENT realpath"),
row("/w\\d", "/w\\d"),
row("/w\\d/z", "/w\\d/z"),
row("/e\\", "/e\\"),
row("/e\\/z", "/e\\/z"),
]);
expect(exitCode).toBe(0);
});

it("promises.realpath is realpath.native", async () => {
const { root } = path.parse(process.cwd());
expect(await promises.realpath(root)).toBe(realpathSync.native(root));
expect(await promises.realpath(root)).toEndWith(path.sep);

using dir = tempDir("fs-promises-realpath", {});
await expect(promises.realpath(join(String(dir), "nope"))).rejects.toMatchObject({
code: "ENOENT",
syscall: "realpath",
});
});

// src/sys/sys.zig getFdPath has an exhaustive per-OS switch: .windows
// (GetFinalPathNameByHandle), .mac (F_GETPATH), .linux (/proc/self/fd, also
// covers Android), .freebsd (fcntl F_KINFO + struct_kinfo_file). On every
Expand Down Expand Up @@ -6896,13 +6995,12 @@ it("sync fs calls read a Buffer path captured at call time when an option getter
expect(exitCode).toBe(0);
});

it.if(isPosix)("realpathSync reports ENAMETOOLONG when cwd plus the path exceeds the system path limit", async () => {
it.if(isPosix)("realpath reports ENAMETOOLONG when cwd plus the path exceeds the system path limit", async () => {
using dir = tempDir("fs-realpath-too-long", {});

// The relative path argument is within the per-argument limit, but joining
// it onto the (non-root) cwd overflows the internal fixed-size path buffer.
// Both realpath variants must surface this as a clean ENAMETOOLONG error
// instead of aborting the process.
// Each relative path is within the per-argument limit and only its absolute
// form is too long. `longPath` does not exist. `deep` does, so the native
// variants open it and find out when they ask for the path of the descriptor.
const script = `
const fs = require("node:fs");
const longPath = "a".repeat(4090);
Expand All @@ -6914,6 +7012,24 @@ it.if(isPosix)("realpathSync reports ENAMETOOLONG when cwd plus the path exceeds
console.log(err.code);
}
}

const levels = ${isLinux ? 12 : 3};
const nested = n => Array(n).fill(Buffer.alloc(200, "d").toString()).join("/");
const deep = nested(levels);
fs.mkdirSync(deep, { recursive: true });
process.chdir(deep);
fs.mkdirSync(deep, { recursive: true });
(async () => {
for (const impl of [fs.realpathSync, fs.realpathSync.native, fs.promises.realpath]) {
try {
await impl(deep);
console.log("resolved");
} catch (err) {
console.log(err.code, err.syscall);
}
}
for (let n = levels; n > 0; n--) fs.rmdirSync(nested(n));
})();
`;

await using proc = Bun.spawn({
Expand All @@ -6926,7 +7042,13 @@ it.if(isPosix)("realpathSync reports ENAMETOOLONG when cwd plus the path exceeds
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).toBe("");
expect(stdout.trim().split("\n")).toEqual(["ENAMETOOLONG", "ENAMETOOLONG"]);
expect(stdout.trim().split("\n")).toEqual([
"ENAMETOOLONG",
"ENAMETOOLONG",
"ENAMETOOLONG lstat",
"ENAMETOOLONG realpath",
"ENAMETOOLONG realpath",
]);
expect(exitCode).toBe(0);
});

Expand Down
26 changes: 25 additions & 1 deletion test/js/node/path/resolve.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows } from "harness";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import assert from "node:assert";
import fs from "node:fs";
// import child from "node:child_process";
import path from "node:path";
// import fixtures from "./common/fixtures.js";
Expand Down Expand Up @@ -200,4 +201,27 @@ describe("path.resolve", () => {
const expectedEnding = isWindows ? "\\c" : "/c";
assert.ok(result.endsWith(expectedEnding));
});

test.skipIf(isWindows)("a backslash at the end of the cwd is part of its name", async () => {
using dir = tempDir("resolve-backslash-cwd", { "e\\/z": "" });

await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const path = require("node:path");
console.log(JSON.stringify([path.resolve("z"), path.resolve("."), path.relative("..", "z")]));`,
],
env: bunEnv,
cwd: path.join(String(dir), "e\\"),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const cwd = fs.realpathSync(String(dir)) + "/e\\";
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual([cwd + "/z", cwd, "e\\/z"]);
expect(exitCode).toBe(0);
});
});
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
Loading