From f0e858f71f0a59dd5ee1dddf31f962a19ee02b26 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:20:03 +0000 Subject: [PATCH 1/3] Preserve literal backslashes in fs.realpath on POSIX On POSIX a backslash is an ordinary filename byte, but realpath resolved its input through a join that treated `\` as a separator and rewrote it to `/`. realpathSync and realpathSync.native then ENOENT on a directory that existsSync and lstatSync resolve in the same process. realpath now resolves with strict POSIX separator semantics via a new abs_buf_checked_posix / join_abs_string_buf_checked_posix that splits on `/` only, so backslashes survive. This is threaded through the existing join via a STRICT_POSIX flag; the shared normalizer is unchanged, so callers that intentionally fold `\` to `/` (module specifiers on Loose, npm lockfile workspace paths) keep their behavior. --- src/paths/resolve_path.rs | 65 ++++++++++++++++++++++++++++++++----- src/resolver/lib.rs | 13 ++++++++ src/runtime/node/node_fs.rs | 2 +- test/js/node/fs/fs.test.ts | 26 +++++++++++++++ 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index eb580783caba..224283faf1c2 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1691,7 +1691,7 @@ pub fn join_abs_string_buf<'a, P: PlatformT>( buf: &'a mut [u8], parts: &[&[u8]], ) -> &'a [u8] { - _join_abs_string_buf::(cwd, buf, parts) + _join_abs_string_buf::(cwd, buf, parts) } /// Like `join_abs_string_buf`, but returns null when the *normalized* result is @@ -1703,6 +1703,26 @@ pub fn join_abs_string_buf_checked<'a, P: PlatformT>( cwd: &'a [u8], buf: &'a mut [u8], parts: &[&[u8]], +) -> Option<&'a [u8]> { + join_abs_string_buf_checked_impl::(cwd, buf, parts) +} + +/// Like `join_abs_string_buf_checked::` but normalizes with strict POSIX +/// separator semantics: `\` stays a literal filename byte instead of acting as +/// a separator. Use this for real on-disk path resolution (e.g. realpath), not +/// for module specifiers, which intentionally treat `\` as a separator. +pub fn join_abs_string_buf_checked_posix<'a>( + cwd: &'a [u8], + buf: &'a mut [u8], + parts: &[&[u8]], +) -> Option<&'a [u8]> { + join_abs_string_buf_checked_impl::(cwd, buf, parts) +} + +fn join_abs_string_buf_checked_impl<'a, P: PlatformT, const STRICT_POSIX: bool>( + cwd: &'a [u8], + buf: &'a mut [u8], + parts: &[&[u8]], ) -> Option<&'a [u8]> { debug_assert!(!matches!(P::P, Platform::Nt)); // Fast path: size check only — don't allocate a JoinScratch here since the @@ -1712,14 +1732,14 @@ pub fn join_abs_string_buf_checked<'a, P: PlatformT>( total += p.len() + 1; } if total < buf.len() { - return Some(join_abs_string_buf::

(cwd, buf, parts)); + return Some(_join_abs_string_buf::(cwd, buf, parts)); } // Slow path: allocate a large scratch for the result. The inner // join_abs_string_buf will heap-allocate its own temp buffer for the concat // since `total > MAX_PATH_BYTES * 2 > sfa inline size` is likely here. let mut scratch = vec![0u8; total]; - let joined = join_abs_string_buf::

(cwd, &mut scratch, parts); + let joined = _join_abs_string_buf::(cwd, &mut scratch, parts); if joined.len() > buf.len() { return None; } @@ -1733,14 +1753,18 @@ pub fn join_abs_string_buf_z<'a, P: PlatformT>( buf: &'a mut [u8], parts: &[&[u8]], ) -> &'a ZStr { - let r = _join_abs_string_buf::(cwd, buf, parts); + let r = _join_abs_string_buf::(cwd, buf, parts); // SAFETY: IS_SENTINEL=true wrote NUL at r.len() unsafe { ZStr::from_raw(r.as_ptr(), r.len()) } } // We always return `&[u8]`; when `IS_SENTINEL` a NUL is written // at `result.len()` and callers (e.g. `join_abs_string_buf_z`) re-wrap as `ZStr`. -fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( +// STRICT_POSIX forces the final normalization to split on `/` only, so a +// backslash stays a literal filename byte (for real on-disk POSIX paths such +// as realpath). It is only honored on the POSIX branch below; leave it false +// everywhere else. +fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT, const STRICT_POSIX: bool>( _cwd: &'a [u8], buf: &'a mut [u8], _parts: &[&[u8]], @@ -1847,10 +1871,14 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( // which writes into buf[leading_len..]). buf[..leading_len].copy_from_slice(&leading_buf[..leading_len]); - let result = normalize_string_buf::( - &temp_buf[leading_len..out], - &mut buf[leading_len..], - ); + let result = if STRICT_POSIX { + normalize_string_posix_buf_t::( + &temp_buf[leading_len..out], + &mut buf[leading_len..], + ) + } else { + normalize_string_buf::(&temp_buf[leading_len..out], &mut buf[leading_len..]) + }; let result_len = result.len(); if IS_SENTINEL { @@ -2027,6 +2055,25 @@ pub(crate) fn normalize_string_loose_buf_t< ) } +// On POSIX `\` is an ordinary filename byte, so unlike the loose normalizer +// this splits segments on `/` only, leaving backslashes intact. +pub(crate) fn normalize_string_posix_buf_t< + 'a, + T: PathChar, + const ALLOW_ABOVE_ROOT: bool, + const PRESERVE_TRAILING_SLASH: bool, +>( + str: &[T], + buf: &'a mut [T], +) -> &'a mut [T] { + normalize_string_generic_t::( + str, + buf, + T::from_u8(SEP_POSIX), + is_sep_posix_t::, + ) +} + pub(crate) fn normalize_string_windows_t< 'a, T: PathChar, diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index b7614dca3651..b8d2df720ec1 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -355,6 +355,19 @@ pub mod fs { join_abs_string_buf_checked::(self.top_level_dir, buf, parts) } + /// Like `abs_buf_checked` but with POSIX separator semantics: a + /// backslash stays a literal filename byte instead of acting as a path + /// separator. Use this for real on-disk path resolution (e.g. + /// realpath), not for module specifiers (those want `Loose`). + pub fn abs_buf_checked_posix<'b>( + &self, + parts: &[&[u8]], + buf: &'b mut [u8], + ) -> Option<&'b [u8]> { + use bun_paths::resolve_path::join_abs_string_buf_checked_posix; + join_abs_string_buf_checked_posix(self.top_level_dir, buf, parts) + } + /// Like `abs_buf` but writes a /// NUL sentinel and returns a `ZStr` borrowing `buf`. pub fn abs_buf_z<'b>(&self, parts: &[&[u8]], buf: &'b mut [u8]) -> &'b ZStr { diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index e8d5a52c7ff9..3ea30944e98d 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -7674,7 +7674,7 @@ impl NodeFS { 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 { + let Some(joined) = fs.abs_buf_checked_posix(&parts, &mut inbuf[..inbuf_len - 1]) else { return Err(sys::Error { errno: E::ENAMETOOLONG as _, syscall: sys::Tag::realpath, diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index bcf0157af57d..c2e93a4f2600 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -2094,6 +2094,32 @@ it.skipIf(isWindows)("realpathSync (getFdPath) is implemented on every POSIX tar expect(realpathSync(resolved)).toBe(resolved); }); +// https://github.com/oven-sh/bun/issues/33403 +// On POSIX a backslash is an ordinary filename byte, so realpath must not +// rewrite it to '/' (that ENOENTs on a directory that exists). +it.if(isPosix)("realpath preserves a literal backslash in the path", async () => { + using dir = tempDir("fs-realpath-backslash", {}); + const root = realpathSync(String(dir)); + const backslashDir = `${root}/back\\slash`; + mkdirSync(backslashDir); + const file = `${backslashDir}/file.txt`; + writeFileSync(file, "x"); + + // Every other fs call already sees the backslash byte on disk. + expect(existsSync(backslashDir)).toBe(true); + expect(lstatSync(backslashDir).isDirectory()).toBe(true); + + const realpathCb = promisify(fs.realpath); + const realpathNativeCb = promisify(fs.realpath.native); + for (const target of [backslashDir, file]) { + expect(realpathSync(target)).toBe(target); + expect(realpathSync.native(target)).toBe(target); + expect(await realpathCb(target)).toBe(target); + expect(await realpathNativeCb(target)).toBe(target); + expect(await promises.realpath(target)).toBe(target); + } +}); + it("readlink", () => { const actual = join(tmpdirSync(), "fs-readlink.txt"); try { From 3ab9cc40d0a791734ed32887005d7686722c9318 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:33:20 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- src/paths/resolve_path.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 224283faf1c2..d64b436de4e3 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -1732,7 +1732,9 @@ fn join_abs_string_buf_checked_impl<'a, P: PlatformT, const STRICT_POSIX: bool>( total += p.len() + 1; } if total < buf.len() { - return Some(_join_abs_string_buf::(cwd, buf, parts)); + return Some(_join_abs_string_buf::( + cwd, buf, parts, + )); } // Slow path: allocate a large scratch for the result. The inner From bf3de47ffab51d018d2fc4a420c559feafc3c1f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:05:29 +0000 Subject: [PATCH 3/3] ci: retrigger