diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index a542eb64cf3a..1826554cf75b 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -7568,32 +7568,12 @@ impl NodeFS { ) -> Maybe { #[cfg(windows)] { - let mut req = UvFsReq::new(); - let rc = unsafe { - uv::uv_fs_realpath( - bun_io::Loop::get(), - &mut *req, - args.path.slice_z(&mut self.sync_error_buf).as_ptr(), - None, - ) - }; - if let Some(err) = rc.to_error(sys::Tag::realpath) { - return Err(err.with_path(args.path.slice())); - } - // `fs_t.ptr` *is* the nullable C - // string pointer (libuv stores the realpath result directly), so - // `ptr_as::()` yields the value, not a pointer-to-Option. - // SAFETY: `rc` was not an error ⇒ libuv populated `req.ptr`. - let ptr: *const c_char = unsafe { req.ptr_as::() }; - if ptr.is_null() { - return Err(sys::Error { - errno: E::ENOENT as _, - syscall: sys::Tag::realpath, - path: args.path.slice().into(), - ..Default::default() - }); - } - let buf = unsafe { bun_core::ffi::cstr(ptr) }.to_bytes(); + let mut outbuf = bun_paths::path_buffer_pool::get(); + let buf: &[u8] = + match sys::realpath(args.path.slice_z(&mut self.sync_error_buf), &mut outbuf) { + Err(err) => return Err(err.with_path(args.path.slice())), + Ok(buf_) => buf_, + }; let _ = variant; if args.encoding == Encoding::Utf8 { if let PathLike::String(s) = &args.path { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index c44a8abbc24d..88a216a62e96 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -4461,12 +4461,46 @@ mod windows_impl { Ok(size.max(0) as u64) } pub fn realpath<'a>(path: &ZStr, buf: &'a mut bun_core::PathBuffer) -> Maybe<&'a [u8]> { - // sys_uv.rs:216 — open + GetFinalPathNameByHandle (uv_fs_realpath edge cases). - let fd = open(path, O::RDONLY, 0)?; - let r = super::get_fd_path(fd, buf); - let _ = close(fd); - // get_fd_path yields `&mut [u8]`; coerce to shared. - r.map(|s| &*s) + let to_error = |e: w::Win32Error| Error::from_win32(e, Tag::realpath); + let bytes = path.as_bytes(); + if !bun_paths::string_paths::fits_in_wide_path_buffer(bytes) { + return Err(Error::from_code(E::ENAMETOOLONG, Tag::realpath)); + } + let mut wpath = bun_paths::w_path_buffer_pool::get(); + let wpath = bun_paths::string_paths::to_w_path(&mut wpath.0[..], bytes); + // SAFETY: `wpath` is NUL-terminated UTF-16; null security attributes + // and template handle are allowed. + let handle = unsafe { + w::CreateFileW( + wpath.as_ptr(), + 0, + 0, + core::ptr::null_mut(), + w::OPEN_EXISTING, + w::FILE_FLAG_BACKUP_SEMANTICS, + core::ptr::null_mut(), + ) + }; + if handle == w::INVALID_HANDLE_VALUE { + return Err(to_error(w::Win32Error::get())); + } + let _close = scopeguard::guard(handle, |handle| { + // SAFETY: `handle` is the live handle opened above. + unsafe { + let _ = w::CloseHandle(handle); + } + }); + let mut wide = bun_paths::w_path_buffer_pool::get(); + let resolved = + match w::GetFinalPathNameByHandle(handle, Default::default(), &mut wide.0[..]) { + Ok(p) => p, + Err(w::GetFinalPathNameByHandleError::NameTooLong) => { + return Err(Error::from_code(E::ENAMETOOLONG, Tag::realpath)); + } + Err(w::GetFinalPathNameByHandleError::Failed(e)) => return Err(to_error(e)), + }; + let len = bun_paths::string_paths::from_w_path(&mut buf.0[..], resolved).len(); + Ok(&buf.0[..len]) } pub fn pipe() -> Maybe<[Fd; 2]> { // uv_pipe(fds, 0, 0). @@ -7707,7 +7741,16 @@ pub fn get_fd_path<'a>(fd: Fd, out: &'a mut bun_paths::PathBuffer) -> Maybe<&'a &mut wide_buf.0[..], ) { Ok(p) => p, - Err(_) => return Err(Error::from_code(E::EBADF, Tag::GetFinalPathNameByHandle)), + Err(e) => { + use crate::windows::GetFinalPathNameByHandleError as GE; + return Err(match e { + GE::Failed(e) => Error::from_win32(e, Tag::GetFinalPathNameByHandle), + GE::NameTooLong => { + Error::from_code(E::ENAMETOOLONG, Tag::GetFinalPathNameByHandle) + } + } + .with_fd(fd)); + } }; // Trust that Windows gives us valid UTF-16LE. let len = bun_paths::string_paths::from_w_path(&mut out.0[..], wide_slice).len(); @@ -7761,13 +7804,10 @@ pub fn get_fd_path<'a>(fd: Fd, out: &'a mut bun_paths::PathBuffer) -> Maybe<&'a pub fn get_fd_path_w(fd: Fd, out: &mut [u16]) -> Maybe<&mut [u16]> { crate::windows::GetFinalPathNameByHandle(fd.native(), Default::default(), out).map_err(|e| { use crate::windows::GetFinalPathNameByHandleError as GE; - Error::from_code( - match e { - GE::FileNotFound => E::ENOENT, - GE::NameTooLong => E::ENAMETOOLONG, - }, - Tag::GetFinalPathNameByHandle, - ) + match e { + GE::Failed(e) => Error::from_win32(e, Tag::GetFinalPathNameByHandle), + GE::NameTooLong => Error::from_code(E::ENAMETOOLONG, Tag::GetFinalPathNameByHandle), + } }) } diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index 8bf44bc47161..59516c19aecc 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -638,8 +638,9 @@ pub use bun_windows_sys::externs::CreateDirectoryExW; #[derive(thiserror::Error, strum::IntoStaticStr, Debug)] pub enum GetFinalPathNameByHandleError { - #[error("FileNotFound")] - FileNotFound, + /// `GetFinalPathNameByHandleW` returned 0; the thread's last error. + #[error("{0:?}")] + Failed(Win32Error), #[error("NameTooLong")] NameTooLong, } @@ -754,7 +755,9 @@ fn lowbox_dos_name_fallback( "GetFinalPathNameByHandleW({:p}) = denied (no NT name)", hFile ); - return Err(GetFinalPathNameByHandleError::FileNotFound); + return Err(GetFinalPathNameByHandleError::Failed( + Win32Error::ACCESS_DENIED, + )); }; let nt = &nt_buf.0[..nt_len]; let Some((device, letter)) = system_volume_device() else { @@ -762,7 +765,9 @@ fn lowbox_dos_name_fallback( "GetFinalPathNameByHandleW({:p}) = denied (system volume unresolved)", hFile ); - return Err(GetFinalPathNameByHandleError::FileNotFound); + return Err(GetFinalPathNameByHandleError::Failed( + Win32Error::ACCESS_DENIED, + )); }; if !(nt.len() > device.len() && nt[..device.len()] == device[..] @@ -773,7 +778,9 @@ fn lowbox_dos_name_fallback( hFile, bun_core::fmt::utf16(nt) ); - return Err(GetFinalPathNameByHandleError::FileNotFound); + return Err(GetFinalPathNameByHandleError::Failed( + Win32Error::ACCESS_DENIED, + )); } let rest = &nt[device.len()..]; let total = 2 + rest.len(); @@ -876,7 +883,9 @@ pub(crate) fn GetFinalPathNameByHandle( { return lowbox_dos_name_fallback(hFile, out_buffer); } - return Err(GetFinalPathNameByHandleError::FileNotFound); + return Err(GetFinalPathNameByHandleError::Failed(Win32Error::from_u32( + err, + ))); } if (return_length as usize) >= out_buffer.len() { diff --git a/test/js/bun/windows/appcontainer.test.ts b/test/js/bun/windows/appcontainer.test.ts index c98daaf770cc..9af0ebb7b9cf 100644 --- a/test/js/bun/windows/appcontainer.test.ts +++ b/test/js/bun/windows/appcontainer.test.ts @@ -1,12 +1,13 @@ import { afterAll, describe, expect, test } from "bun:test"; import { execSync } from "child_process"; -import { existsSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { bunExe, isWindows, tempDirWithFiles } from "harness"; import { dirname, join } from "path"; // Launches bun inside a real Windows AppContainer (lowbox token) and asserts // the sandbox-only behaviors: spawn with piped stdio, the LOCAL\ pipe -// namespace, realpath denial, and the rewritten TEMP. No admin rights needed. +// namespace, realpath inside the sandbox, and the rewritten TEMP. No admin +// rights needed. // // The launch is done with bun:ffi (CreateAppContainerProfile + // PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES); results are written by the @@ -252,12 +253,21 @@ async function main() { const s = Bun.spawnSync({ cmd: [process.execPath, "-e", "console.log('SPAWN_OK')"], stdio: ["inherit", "pipe", "pipe"] }); r.spawnPiped = s.exitCode === 0 && s.stdout.toString().includes("SPAWN_OK"); - // User-facing fs.realpath stays Node-parity: the component walk lstats the - // drive root (denied), and uv_fs_realpath's mount-manager query is denied. - // Bun internals go through get_fd_path, not these. - r.realpath = (() => { - const errno = fn => { try { fn(); return "OK"; } catch (e) { return e.code || e.name; } }; - return { sync: errno(() => fs.realpathSync(".")), native: errno(() => fs.realpathSync.native(".")) }; + // The native realpath variants open the path attribute-only and take the + // final name from the handle, with the system-volume fallback for the + // denied mount-manager query, so they answer inside the container. The + // Node-ported component walk (fs.realpathSync, callback fs.realpath) lstats + // the drive root, which the container cannot read, and stays at EPERM. + // Each entry is the resolved path, or the error code. The parent compares + // against the answer it gets outside the container. + r.realpath = await (async () => { + const result = async fn => { try { return await fn(); } catch (e) { return e.code || e.name; } }; + return { + sync: await result(() => fs.realpathSync(".")), + native: await result(() => fs.realpathSync.native(".")), + nativeCallback: await result(() => new Promise((res, rej) => fs.realpath.native(".", (e, p) => (e ? rej(e) : res(p))))), + promises: await result(() => fs.promises.realpath(".")), + }; })(); r.pipeNonLocal = await new Promise(resolve => { @@ -339,7 +349,20 @@ main().then( // AppContainer children to the package's AC\Temp. expect(r.tempRewritten).toBe(true); expect(r.spawnPiped).toBe(true); - expect(r.realpath).toEqual({ sync: "EPERM", native: "EPERM" }); + // The container's cwd is workDir; the native variants resolve it to the + // same final path the host sees. The fallback for the denied DOS volume + // name answers only on the system volume, so a temp directory on another + // drive still gets EPERM. The Node-ported component walk stays at EPERM + // everywhere: it lstats the drive root, which the container cannot read. + const hostRealpath = realpathSync.native(workDir); + const systemDrive = (process.env.SystemDrive ?? "C:").toUpperCase(); + const nativeExpected = hostRealpath.toUpperCase().startsWith(systemDrive + "\\") ? hostRealpath : "EPERM"; + expect(r.realpath).toEqual({ + sync: "EPERM", + native: nativeExpected, + nativeCallback: nativeExpected, + promises: nativeExpected, + }); // Namespace denial vs name collision both surface as ERROR_ACCESS_DENIED; // stock uv_pipe_bind2 maps that to EADDRINUSE. Tighten to EACCES once // the disambiguation probe lands on the libuv side.