Skip to content
Closed
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
67 changes: 58 additions & 9 deletions src/paths/resolve_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<false, P>(cwd, buf, parts)
_join_abs_string_buf::<false, P, false>(cwd, buf, parts)
}

/// Like `join_abs_string_buf`, but returns null when the *normalized* result is
Expand All @@ -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::<P, false>(cwd, buf, parts)
}

/// Like `join_abs_string_buf_checked::<Posix>` 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::<platform::Posix, true>(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
Expand All @@ -1712,14 +1732,16 @@ pub fn join_abs_string_buf_checked<'a, P: PlatformT>(
total += p.len() + 1;
}
if total < buf.len() {
return Some(join_abs_string_buf::<P>(cwd, buf, parts));
return Some(_join_abs_string_buf::<false, P, STRICT_POSIX>(
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::<P>(cwd, &mut scratch, parts);
let joined = _join_abs_string_buf::<false, P, STRICT_POSIX>(cwd, &mut scratch, parts);
if joined.len() > buf.len() {
return None;
}
Expand All @@ -1733,14 +1755,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::<true, P>(cwd, buf, parts);
let r = _join_abs_string_buf::<true, P, false>(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]],
Expand Down Expand Up @@ -1847,10 +1873,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::<false, P, true>(
&temp_buf[leading_len..out],
&mut buf[leading_len..],
);
let result = if STRICT_POSIX {
normalize_string_posix_buf_t::<u8, false, true>(
&temp_buf[leading_len..out],
&mut buf[leading_len..],
)
} else {
normalize_string_buf::<false, P, true>(&temp_buf[leading_len..out], &mut buf[leading_len..])
};
let result_len = result.len();

if IS_SENTINEL {
Expand Down Expand Up @@ -2027,6 +2057,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::<T, ALLOW_ABOVE_ROOT, PRESERVE_TRAILING_SLASH>(
str,
buf,
T::from_u8(SEP_POSIX),
is_sep_posix_t::<T>,
)
}

pub(crate) fn normalize_string_windows_t<
'a,
T: PathChar,
Expand Down
13 changes: 13 additions & 0 deletions src/resolver/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,19 @@ pub mod fs {
join_abs_string_buf_checked::<platform::Loose>(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 {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
robobun marked this conversation as resolved.
Resolved
return Err(sys::Error {
errno: E::ENAMETOOLONG as _,
syscall: sys::Tag::realpath,
Expand Down
26 changes: 26 additions & 0 deletions test/js/node/fs/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading