diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 9175e67e2677..a22db321cfe9 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -2855,6 +2855,12 @@ unsafe extern "C" { path: *const c_char, cb: uv_fs_cb, ) -> ReturnCode; + pub fn uv_utf16_to_wtf8( + utf16: *const u16, + utf16_len: isize, + wtf8_ptr: *mut *mut c_char, + wtf8_len_ptr: *mut usize, + ) -> ReturnCode; pub fn uv_fs_stat( loop_: *mut Loop, req: *mut fs_t, diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 828858d06994..c8eea1e491d7 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -441,18 +441,21 @@ fn openat_os_path(dirfd: FD, path: &OSPathSliceZ, flags: i32, mode: Mode) -> May sys::openat_windows(dirfd, path.as_slice(), flags, mode) } -/// Check whether a directory exists at `(fd, path)` — dispatches on path element width. On -/// Windows `OSPathSliceZ` is already `&WStr`, so forward to the wide overload -/// instead of narrowing to UTF-8 and re-widening. POSIX is a forwarder. +/// Match mkdir's path semantics when checking an existing directory. #[inline] -fn directory_exists_at_os_path(dir: FD, path: &OSPathSliceZ) -> Maybe { +fn directory_exists_os_path(path: &OSPathSliceZ) -> Maybe { #[cfg(not(windows))] { - sys::directory_exists_at(dir, path) + sys::directory_exists_at(FD::INVALID, path) } #[cfg(windows)] { - sys::directory_exists_at_w(dir, path.as_slice()) + // Win32 resolves relative dot components using the logical cwd, including junctions. + match Syscall::stat_w(path) { + Ok(st) => Ok(sys::S::ISDIR(st.st_mode as _)), + Err(err) if err.get_errno() == E::ENOENT => Ok(false), + Err(err) => Err(err), + } } } @@ -5470,7 +5473,7 @@ impl NodeFS { // it is unclear if macOS lies about if the existing item is // a directory or not, so it is checked. E::EISDIR | E::EEXIST => { - return match directory_exists_at_os_path(FD::INVALID, path) { + return match directory_exists_os_path(path) { Err(_) => Err(sys::Error { errno: err.errno, syscall: sys::Tag::mkdir, @@ -5562,9 +5565,7 @@ impl NodeFS { // On Windows, this may happen if trying to mkdir replacing a file #[cfg(windows)] { - if let Ok(res) = - directory_exists_at_os_path(FD::INVALID, parent) - { + if let Ok(res) = directory_exists_os_path(parent) { // is a directory. break. if !res { // SAFETY: `working_mem` is not used after this return; the diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 6701a0b46b1e..d6f129c8235b 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7114,10 +7114,7 @@ pub enum ExistsAtType { Directory, } /// Windows tail — `NtQueryAttributesFile` against an -/// OBJECT_ATTRIBUTES built from an already NT-prefixed wide path. Shared by the -/// UTF-8 (`exists_at_type`) and UTF-16 (`exists_at_type_w`) entry points so the -/// width dispatch does not -/// duplicate the syscall body. +/// OBJECT_ATTRIBUTES built from an already NT-prefixed wide path. #[cfg(windows)] fn exists_at_type_nt(dir: Fd, mut path: &[u16]) -> Maybe { use bun_windows_sys::externs as w; @@ -7186,15 +7183,6 @@ pub fn exists_at_type(dir: Fd, sub: &ZStr) -> Maybe { exists_at_type_nt(dir, path) } } -/// Wide-path arm of `exists_at_type`. Takes an already-wide path (Windows -/// `OSPathSliceZ`) and routes through -/// `toNTPath16` instead of re-widening from UTF-8. -#[cfg(windows)] -pub(crate) fn exists_at_type_w(dir: Fd, sub: &[u16]) -> Maybe { - let mut wbuf = bun_paths::w_path_buffer_pool::get(); - let path = bun_paths::string_paths::to_nt_path16(&mut wbuf.0[..], sub).as_slice(); - exists_at_type_nt(dir, path) -} /// `directoryExistsAt(dir, sub)`. ENOENT → `Ok(false)`. pub fn directory_exists_at(dir: impl AsFd, sub: &ZStr) -> Maybe { let dir = dir.as_fd(); @@ -7204,18 +7192,6 @@ pub fn directory_exists_at(dir: impl AsFd, sub: &ZStr) -> Maybe { Err(e) => Err(e), } } -/// `directoryExistsAt` — wide-path (`u16`) overload for Windows -/// `OSPathSliceZ` callers (mkdir-recursive, cpSync auto-detect). Avoids -/// a UTF-16 → UTF-8 → UTF-16 round-trip. -#[cfg(windows)] -pub fn directory_exists_at_w(dir: Fd, sub: &[u16]) -> Maybe { - match exists_at_type_w(dir, sub) { - Ok(t) => Ok(t == ExistsAtType::Directory), - Err(e) if e.get_errno() == E::ENOENT => Ok(false), - Err(e) => Err(e), - } -} - // ── fcntl / nonblocking / dup ── /// `fcntl(fd, F_GETFL, 0)`. diff --git a/src/sys/sys_uv.rs b/src/sys/sys_uv.rs index 9cf9c4ef936b..91e3fb844ccc 100644 --- a/src/sys/sys_uv.rs +++ b/src/sys/sys_uv.rs @@ -517,6 +517,20 @@ pub fn stat(path: &ZStr) -> Result { } } +/// Preserve Windows filename code units while using libuv's Win32 stat semantics. +pub fn stat_w(path: &bun_core::WStr) -> Result { + let mut buf = bun_paths::path_buffer_pool::get(); + let mut ptr = buf.as_mut_ptr().cast::(); + let mut len = buf.len() - 1; + // SAFETY: path covers its reported length; buf has len bytes plus libuv's NUL terminator. + let rc = + unsafe { uv::uv_utf16_to_wtf8(path.as_ptr(), path.len() as isize, &mut ptr, &mut len) }; + if let Some(errno) = rc.errno() { + return Err(Error::new(errno, Tag::stat)); + } + stat(ZStr::from_buf(&buf[..], len)) +} + pub fn lstat(path: &ZStr) -> Result { let mut req = FsReq::new(); // SAFETY: synchronous libuv fs call; req lives on the stack for the duration. diff --git a/test/js/node/fs/fs-mkdir.test.ts b/test/js/node/fs/fs-mkdir.test.ts index 5a910063c2eb..197b6e04e7ec 100644 --- a/test/js/node/fs/fs-mkdir.test.ts +++ b/test/js/node/fs/fs-mkdir.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { isLinux, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; import { execSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; @@ -305,6 +305,74 @@ describe("fs.mkdir - return values", () => { }); }); +describe.skipIf(!isWindows)("fs.mkdir - recursive Windows relative paths", () => { + it.each(["sync", "promise", "callback"])("preserves dot components and logical junction cwd (%s)", async method => { + using dir = tempDir("mkdir-relative", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const assert = require("node:assert/strict"); + const fs = require("node:fs"); + const path = require("node:path"); + const { promisify } = require("node:util"); + const root = process.env.MKDIR_ROOT; + const method = process.env.MKDIR_VARIANT; + const mkdir = method === "sync" ? fs.mkdirSync : method === "promise" ? fs.promises.mkdir : promisify(fs.mkdir); + const logical = path.join(root, "logical"); + const work = path.join(logical, "work"); + const physical = path.join(root, "physical"); + const target = path.join(physical, "target"); + fs.mkdirSync(work, { recursive: true }); + fs.mkdirSync(path.join(logical, "sibling")); + fs.writeFileSync(path.join(logical, "file"), "unchanged"); + fs.mkdirSync(target, { recursive: true }); + const junction = path.join(logical, "junction"); + fs.symlinkSync(target, junction, "junction"); + process.chdir(work); + const driveRelative = path.parse(work).root.slice(0, 2) + path.join("..", "sibling"); + for (const input of [driveRelative, ".", "..", "../sibling", "..\\\\sibling", ".\\\\..\\\\sibling", work + "\\\\..\\\\sibling"]) { + assert.equal(await mkdir(input, { recursive: true }), undefined); + await assert.rejects(async () => mkdir(input, { recursive: false }), { code: "EEXIST" }); + } + await assert.rejects(async () => mkdir("../file", { recursive: true }), { code: "EEXIST" }); + const unpaired = Buffer.concat([Buffer.from("../unpaired-"), Buffer.from([0xed, 0xa0, 0x80])]); + fs.mkdirSync(unpaired); + assert.equal(fs.statSync(unpaired).isDirectory(), true); + assert.equal(await mkdir(unpaired, { recursive: true }), undefined); + const firstCreated = path.join("..", "created"); + const created = await mkdir(path.join(firstCreated, "nested"), { recursive: true }); + assert.equal(typeof created, "string"); + const createdStat = fs.statSync(created, { bigint: true }); + const expectedStat = fs.statSync(path.join(logical, "created"), { bigint: true }); + assert.deepEqual([createdStat.dev, createdStat.ino], [expectedStat.dev, expectedStat.ino]); + assert.equal(fs.statSync(path.join(logical, "created", "nested")).isDirectory(), true); + process.chdir(junction); + assert.equal(process.cwd(), junction); + assert.equal(await mkdir("../sibling", { recursive: true }), undefined); + assert.equal(fs.existsSync(path.join(physical, "sibling")), false); + const firstJunctionCreated = path.join("..", "junction-created"); + const junctionCreated = await mkdir(path.join(firstJunctionCreated, "nested"), { recursive: true }); + assert.equal(typeof junctionCreated, "string"); + const junctionStat = fs.statSync(junctionCreated, { bigint: true }); + const expectedJunctionStat = fs.statSync(path.join(logical, "junction-created"), { bigint: true }); + assert.deepEqual([junctionStat.dev, junctionStat.ino], [expectedJunctionStat.dev, expectedJunctionStat.ino]); + assert.equal(fs.statSync(path.join(logical, "junction-created", "nested")).isDirectory(), true); + assert.equal(fs.existsSync(path.join(physical, "junction-created")), false); + assert.equal(fs.readFileSync(path.join(logical, "file"), "utf8"), "unchanged"); + console.log("ok"); + `, + ], + env: { ...bunEnv, MKDIR_ROOT: String(dir), MKDIR_VARIANT: method }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "ok\n", stderr: "", exitCode: 0 }); + }); +}); + // https://github.com/oven-sh/bun/issues/34413 describe.skipIf(!isWindows)("fs.mkdir - recursive with ReadOnly attribute (Windows)", () => { let tmpdir: string;