Skip to content
Open
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
32 changes: 6 additions & 26 deletions src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7568,32 +7568,12 @@ impl NodeFS {
) -> Maybe<ret::Realpath> {
#[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::<c_char>()` 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::<c_char>() };
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 {
Expand Down
68 changes: 54 additions & 14 deletions src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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)),
Comment thread
robobun marked this conversation as resolved.
Resolved
};
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).
Expand Down Expand Up @@ -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)
}
Comment thread
robobun marked this conversation as resolved.
Resolved
}
.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();
Expand Down Expand Up @@ -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),
}
})
}

Expand Down
21 changes: 15 additions & 6 deletions src/sys/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -754,15 +755,19 @@ 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 {
bun_sys::syslog!(
"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[..]
Expand All @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
41 changes: 32 additions & 9 deletions test/js/bun/windows/appcontainer.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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,
});
Comment thread
robobun marked this conversation as resolved.
Resolved
// 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.
Expand Down
Loading