diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 09caf024833..261e56aaa51 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -2909,32 +2909,8 @@ pub mod args { // `Drop for PathLike` covers every // error return below (including `validate_integer`). let path = PathLike::from_js_required(ctx, arguments, "path")?; - let uid: UidT = 'brk: { - let Some(uid_value) = arguments.next() else { - return Err(ctx.throw_invalid_arguments(format_args!("uid is required"))); - }; - arguments.eat(); - break 'brk wrap_to::(validators::validate_integer( - ctx, - uid_value, - "uid", - Some(-1), - Some(u32::MAX as i64), - )?); - }; - let gid: GidT = 'brk: { - let Some(gid_value) = arguments.next() else { - return Err(ctx.throw_invalid_arguments(format_args!("gid is required"))); - }; - arguments.eat(); - break 'brk wrap_to::(validators::validate_integer( - ctx, - gid_value, - "gid", - Some(-1), - Some(u32::MAX as i64), - )?); - }; + let uid = id_from_js(ctx, arguments, "uid")?; + let gid = id_from_js(ctx, arguments, "gid")?; Ok(Chown { path, uid, gid }) } } @@ -2948,32 +2924,8 @@ pub mod args { pub fn to_thread_safe(&self) {} pub fn from_js(ctx: &JSGlobalObject, arguments: &mut ArgumentsSlice) -> JsResult { let fd = FD::from_js_required(ctx, arguments)?; - let uid: UidT = 'brk: { - let Some(uid_value) = arguments.next() else { - return Err(ctx.throw_invalid_arguments(format_args!("uid is required"))); - }; - arguments.eat(); - break 'brk wrap_to::(validators::validate_integer( - ctx, - uid_value, - "uid", - Some(-1), - Some(u32::MAX as i64), - )?); - }; - let gid: GidT = 'brk: { - let Some(gid_value) = arguments.next() else { - return Err(ctx.throw_invalid_arguments(format_args!("gid is required"))); - }; - arguments.eat(); - break 'brk wrap_to::(validators::validate_integer( - ctx, - gid_value, - "gid", - Some(-1), - Some(u32::MAX as i64), - )?); - }; + let uid = id_from_js(ctx, arguments, "uid")?; + let gid = id_from_js(ctx, arguments, "gid")?; Ok(Fchown { fd, uid, gid }) } } @@ -2992,8 +2944,58 @@ pub mod args { T::from(in_ as u8) } + /// Reads a required `uid`/`gid` argument, validated to `[-1, u32::MAX]`. + /// `uid_t` and `gid_t` are the same primitive on every supported platform + /// (`u32` on POSIX, libuv's `unsigned char` on Windows), so one reader + /// serves both. + fn id_from_js( + ctx: &JSGlobalObject, + arguments: &mut ArgumentsSlice, + name: &str, + ) -> JsResult { + let Some(value) = arguments.next() else { + return Err(ctx.throw_invalid_arguments(format_args!("{name} is required"))); + }; + arguments.eat(); + Ok(wrap_to(validators::validate_integer( + ctx, + value, + name, + Some(-1), + Some(u32::MAX as i64), + )?)) + } + pub type LChown = Chown; + fn time_arg_from_js( + ctx: &JSGlobalObject, + arguments: &mut ArgumentsSlice, + name: &str, + ) -> JsResult { + let time = node::time_like_from_js( + ctx, + arguments + .next() + .ok_or_else(|| ctx.throw_invalid_arguments(format_args!("{name} is required")))?, + )? + .ok_or_else(|| { + ctx.throw_invalid_arguments(format_args!("{name} must be a number or a Date")) + })?; + arguments.eat(); + Ok(time) + } + + /// Parse the `atime, mtime` argument pair shared by `utimes`/`lutimes`/`futimes`. + fn times_from_js( + ctx: &JSGlobalObject, + arguments: &mut ArgumentsSlice, + ) -> JsResult<(TimeLike, TimeLike)> { + let atime = time_arg_from_js(ctx, arguments, "atime")?; + let mtime = time_arg_from_js(ctx, arguments, "mtime")?; + Ok((atime, mtime)) + } + pub struct Lutimes { pub path: PathLike, pub atime: TimeLike, @@ -3003,28 +3005,9 @@ pub mod args { impl Lutimes { pub fn from_js(ctx: &JSGlobalObject, arguments: &mut ArgumentsSlice) -> JsResult { // `Drop for PathLike` covers the - // `time_like_from_js` throws below. + // `times_from_js` throws below. let path = PathLike::from_js_required(ctx, arguments, "path")?; - let atime = node::time_like_from_js( - ctx, - arguments.next().ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("atime is required")) - })?, - )? - .ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("atime must be a number or a Date")) - })?; - arguments.eat(); - let mtime = node::time_like_from_js( - ctx, - arguments.next().ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("mtime is required")) - })?, - )? - .ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("mtime must be a number or a Date")) - })?; - arguments.eat(); + let (atime, mtime) = times_from_js(ctx, arguments)?; Ok(Lutimes { path, atime, mtime }) } } @@ -3666,26 +3649,7 @@ pub mod args { pub fn to_thread_safe(&self) {} pub fn from_js(ctx: &JSGlobalObject, arguments: &mut ArgumentsSlice) -> JsResult { let fd = FD::from_js_required(ctx, arguments)?; - let atime = node::time_like_from_js( - ctx, - arguments.next().ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("atime is required")) - })?, - )? - .ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("atime must be a number or a Date")) - })?; - arguments.eat(); - let mtime = node::time_like_from_js( - ctx, - arguments.next().ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("mtime is required")) - })?, - )? - .ok_or_else(|| { - ctx.throw_invalid_arguments(format_args!("mtime must be a number or a Date")) - })?; - arguments.eat(); + let (atime, mtime) = times_from_js(ctx, arguments)?; Ok(Futimes { fd, atime, mtime }) } } @@ -4808,6 +4772,18 @@ impl Default for NodeFS { // yet in-tree) keep working via `node::fs::ReturnType::Foo`. pub use ret as ReturnType; +/// How `copy_file_range_with_fallbacks` handles EINTR from copy_file_range(2), +/// preserving the historical split between its two callers: `fs.copyFile` +/// retries while `fs.cp` surfaces it (matching the original implementations). +#[cfg(any(target_os = "linux", target_os = "android"))] +#[derive(Clone, Copy, PartialEq, Eq)] +enum EintrPolicy { + /// Retry the syscall (`fs.copyFile`). + Retry, + /// Surface EINTR to the caller as an error (`fs.cp`). + Surface, +} + impl NodeFS { pub fn access(&mut self, args: &args::Access, _: Flavor) -> Maybe { // The `bun_sys::access` Windows @@ -5022,6 +4998,105 @@ impl NodeFS { Ok(()) } + /// Shared Linux copy loop for `fs.copyFile` and `fs.cp` once both fds are + /// open and the ioctl_ficlone fast path has been ruled out: drains + /// `src_fd` into `dest_fd` via copy_file_range(2) (Linux 5.3+; not + /// supported in gVisor), falling back to sendfile/read-write when the + /// syscall is unavailable or the filesystem rejects it. Takes ownership of + /// `dest_fd`: on every exit path it is ftruncated to the bytes written, + /// fchmod'd to `st_mode`, and closed. + #[cfg(any(target_os = "linux", target_os = "android"))] + fn copy_file_range_with_fallbacks( + src: &ZStr, + dest: &ZStr, + src_fd: FD, + dest_fd: FD, + st_mode: Mode, + mut size: usize, + eintr_policy: EintrPolicy, + ) -> Maybe { + // `wrote` is read by the deferred-close scopeguard *after* the copy + // loop below mutates it. `Cell` lets the guard borrow by + // reference while the loop `get`s/`set`s, so the value observed at + // scope-exit time is the final one. + let wrote: core::cell::Cell = core::cell::Cell::new(0); + let _close_dest = scopeguard::guard((dest_fd, st_mode, &wrote), |(fd, m, wrote)| { + // ftruncate/fchmod take only ints — no memory-safety preconditions; + // route through the existing `bun_sys` safe wrappers. + let _ = Syscall::ftruncate(fd, (wrote.get() & ((1u64 << 63) - 1)) as i64); + let _ = Syscall::fchmod(fd, m); + fd.close(); + }); + + let mut off_in_copy: i64 = 0; + let mut off_out_copy: i64 = 0; + + if !sys::copy_file::can_use_copy_file_range_syscall() { + let mut w = wrote.get(); + let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback( + src, dest, src_fd, dest_fd, size, &mut w, + ); + wrote.set(w); + return r; + } + + // size == 0 means the source stat'd as empty (e.g. procfs): copy + // page-sized chunks until EOF instead of trusting the stat size. + let until_eof = size == 0; + loop { + let chunk = if until_eof { sys::page_size() } else { size }; + // SAFETY: src_fd/dest_fd are valid open fds; copy_file_range is the libc FFI + let written = unsafe { + sys::linux::copy_file_range( + src_fd.native(), + &raw mut off_in_copy, + dest_fd.native(), + &raw mut off_out_copy, + chunk, + 0, + ) + }; + if let Some(err) = Maybe::::errno_sys_p( + written, + sys::Tag::copy_file_range, + dest.as_bytes(), + ) { + match err.get_errno() { + E::EINTR if eintr_policy == EintrPolicy::Retry => continue, + // EINVAL: eCryptfs and other filesystems may not support copy_file_range + // XDEV: cross-device copy not supported + // NOSYS: syscall not available + // OPNOTSUPP: filesystem doesn't support this operation + E::EXDEV | E::ENOSYS | E::EINVAL | E::EOPNOTSUPP => { + if matches!(err.get_errno(), E::ENOSYS | E::EOPNOTSUPP) { + sys::copy_file::disable_copy_file_range_syscall(); + } + let mut w = wrote.get(); + let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback( + src, dest, src_fd, dest_fd, size, &mut w, + ); + wrote.set(w); + return r; + } + _ => return err, + } + } + // wrote zero bytes means EOF + if written == 0 { + break; + } + wrote.set(wrote.get().saturating_add(written as u64)); + if !until_eof { + size = size.saturating_sub(written as usize); + if size == 0 { + break; + } + } + } + + Ok(()) + } + pub fn copy_file(&mut self, args: &args::CopyFile, _: Flavor) -> Maybe { match self.copy_file_inner(args) { Ok(_) => Ok(()), @@ -5274,20 +5349,13 @@ impl NodeFS { } let mut flags: i32 = sys::O::CREAT | sys::O::WRONLY; - // VERIFY-FIX(round1): `wrote` is read by the deferred-close scopeguard - // *after* the copy loops below mutate it. As a `usize` captured by-copy - // the guard always saw 0, and the `&mut (wrote as u64)` call sites - // wrote into discarded temporaries. `Cell` lets the guard borrow - // by reference while the loops `get`/`set`, so the value observed at - // scope-exit time is the final one. - let wrote: core::cell::Cell = core::cell::Cell::new(0); if args.mode.shouldnt_overwrite() { flags |= sys::O::EXCL; } let dest_fd = Syscall::open(dest, flags, DEFAULT_PERMISSION)?; - let mut size: usize = stat_.st_size.max(0) as usize; + let size: usize = stat_.st_size.max(0) as usize; // https://manpages.debian.org/testing/manpages-dev/ioctl_ficlone.2.en.html if args.mode.is_force_clone() { @@ -5319,109 +5387,15 @@ impl NodeFS { sys::copy_file::disable_ioctl_ficlone(); } - let _close_dest = - scopeguard::guard((dest_fd, stat_.st_mode, &wrote), |(fd, m, wrote)| { - // ftruncate/fchmod take only ints — no memory-safety preconditions; route - // through the existing `bun_sys` safe wrappers (same as lines above). - let _ = Syscall::ftruncate(fd, (wrote.get() & ((1u64 << 63) - 1)) as i64); - let _ = Syscall::fchmod(fd, m as u32); - fd.close(); - }); - - let mut off_in_copy: i64 = 0; - let mut off_out_copy: i64 = 0; - - if !sys::copy_file::can_use_copy_file_range_syscall() { - let mut w = wrote.get(); - let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback( - src, dest, src_fd, dest_fd, size, &mut w, - ); - wrote.set(w); - return r; - } - - if size == 0 { - // copy until EOF - loop { - // Linux Kernel 5.3 or later - // Not supported in gVisor - // SAFETY: src_fd/dest_fd are valid open fds; copy_file_range is the libc FFI - let written = unsafe { - sys::linux::copy_file_range( - src_fd.native(), - &raw mut off_in_copy, - dest_fd.native(), - &raw mut off_out_copy, - sys::page_size(), - 0, - ) - }; - if let Some(err) = Maybe::::errno_sys_p( - written, - sys::Tag::copy_file_range, - dest, - ) { - match err.get_errno() { - E::EINTR => continue, - E::EXDEV | E::ENOSYS | E::EINVAL | E::EOPNOTSUPP => { - if matches!(err.get_errno(), E::ENOSYS | E::EOPNOTSUPP) { - sys::copy_file::disable_copy_file_range_syscall(); - } - let mut w = wrote.get(); - let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback(src, dest, src_fd, dest_fd, size, &mut w); - wrote.set(w); - return r; - } - _ => return err, - } - } - // wrote zero bytes means EOF - if written == 0 { - break; - } - wrote.set(wrote.get().saturating_add(written as u64)); - } - } else { - while size > 0 { - // SAFETY: src_fd/dest_fd are valid open fds; copy_file_range is the libc FFI - let written = unsafe { - sys::linux::copy_file_range( - src_fd.native(), - &raw mut off_in_copy, - dest_fd.native(), - &raw mut off_out_copy, - size, - 0, - ) - }; - if let Some(err) = Maybe::::errno_sys_p( - written, - sys::Tag::copy_file_range, - dest, - ) { - match err.get_errno() { - E::EINTR => continue, - E::EXDEV | E::ENOSYS | E::EINVAL | E::EOPNOTSUPP => { - if matches!(err.get_errno(), E::ENOSYS | E::EOPNOTSUPP) { - sys::copy_file::disable_copy_file_range_syscall(); - } - let mut w = wrote.get(); - let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback(src, dest, src_fd, dest_fd, size, &mut w); - wrote.set(w); - return r; - } - _ => return err, - } - } - if written == 0 { - break; - } - wrote.set(wrote.get().saturating_add(written as u64)); - size = size.saturating_sub(written as usize); - } - } - - return Ok(()); + return Self::copy_file_range_with_fallbacks( + src, + dest, + src_fd, + dest_fd, + stat_.st_mode as Mode, + size, + EintrPolicy::Retry, + ); } #[cfg(windows)] @@ -8104,12 +8078,26 @@ impl NodeFS { Maybe::::todo() } - pub fn utimes(&mut self, args: &args::Utimes, _: Flavor) -> Maybe { + /// Shared body of [`Self::utimes`] / [`Self::lutimes`]; they differ only + /// in which syscall applies the timestamps. + fn utimes_with( + &mut self, + args: &args::Utimes, + #[cfg(windows)] uv_utime: unsafe extern "C" fn( + *mut uv::Loop, + *mut uv::fs_t, + *const core::ffi::c_char, + f64, + f64, + uv::uv_fs_cb, + ) -> uv::ReturnCode, + #[cfg(not(windows))] utimens: fn(&ZStr, sys::TimeLike, sys::TimeLike) -> Maybe<()>, + ) -> Maybe { #[cfg(windows)] { let mut req = UvFsReq::new(); let rc = unsafe { - uv::uv_fs_utime( + uv_utime( bun_io::Loop::get(), &mut *req, args.path.slice_z(&mut self.sync_error_buf).as_ptr(), @@ -8130,7 +8118,7 @@ impl NodeFS { }; } #[cfg(not(windows))] - match Syscall::utimens( + match utimens( args.path.slice_z(&mut self.sync_error_buf), to_sys_time_like(args.atime), to_sys_time_like(args.mtime), @@ -8140,40 +8128,18 @@ impl NodeFS { } } + pub fn utimes(&mut self, args: &args::Utimes, _: Flavor) -> Maybe { + #[cfg(windows)] + return self.utimes_with(args, uv::uv_fs_utime); + #[cfg(not(windows))] + self.utimes_with(args, Syscall::utimens) + } + pub fn lutimes(&mut self, args: &args::Lutimes, _: Flavor) -> Maybe { #[cfg(windows)] - { - let mut req = UvFsReq::new(); - let rc = unsafe { - uv::uv_fs_lutime( - bun_io::Loop::get(), - &mut *req, - args.path.slice_z(&mut self.sync_error_buf).as_ptr(), - args.atime, - args.mtime, - None, - ) - }; - return if let Some(errno) = rc.errno() { - Err(sys::Error { - errno, - syscall: sys::Tag::utime, - path: args.path.slice().into(), - ..Default::default() - }) - } else { - Ok(()) - }; - } + return self.utimes_with(args, uv::uv_fs_lutime); #[cfg(not(windows))] - match Syscall::lutimens( - args.path.slice_z(&mut self.sync_error_buf), - to_sys_time_like(args.atime), - to_sys_time_like(args.mtime), - ) { - Err(err) => Err(err.with_path(args.path.slice())), - Ok(_) => Ok(()), - } + self.utimes_with(args, Syscall::lutimens) } pub fn watch(&mut self, args: &args::Watch<'_>, _: Flavor) -> Maybe { @@ -8722,14 +8688,13 @@ impl NodeFS { } let mut flags: i32 = sys::O::CREAT | sys::O::WRONLY; - let wrote: core::cell::Cell = core::cell::Cell::new(0); if mode.shouldnt_overwrite() { flags |= sys::O::EXCL; } let dest_fd = Self::_cp_open_dest_with_mkdir(self, dest, flags)?; - let mut size: usize = stat_.st_size.max(0) as usize; + let size: usize = stat_.st_size.max(0) as usize; if sys::S::ISREG(stat_.st_mode as u32) && sys::copy_file::can_use_ioctl_ficlone() { let rc = sys::linux::ioctl_ficlone(dest_fd, src_fd); @@ -8741,118 +8706,15 @@ impl NodeFS { sys::copy_file::disable_ioctl_ficlone(); } - let _close_dest = scopeguard::guard( - (dest_fd, stat_.st_mode as Mode, &wrote), - |(fd, m, wrote)| { - let _ = Syscall::ftruncate(fd, (wrote.get() & ((1u64 << 63) - 1)) as i64); - let _ = Syscall::fchmod(fd, m); - fd.close(); - }, + return Self::copy_file_range_with_fallbacks( + src, + dest, + src_fd, + dest_fd, + stat_.st_mode as Mode, + size, + EintrPolicy::Surface, ); - - let mut off_in_copy: i64 = 0; - let mut off_out_copy: i64 = 0; - - if !sys::copy_file::can_use_copy_file_range_syscall() { - let mut w = wrote.get(); - let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback( - src, dest, src_fd, dest_fd, size, &mut w, - ); - wrote.set(w); - return r; - } - - if size == 0 { - // copy until EOF - loop { - // Linux Kernel 5.3 or later - // Not supported in gVisor - // SAFETY: src_fd/dest_fd are valid open fds; copy_file_range is the libc FFI - let written = unsafe { - sys::linux::copy_file_range( - src_fd.native(), - &raw mut off_in_copy, - dest_fd.native(), - &raw mut off_out_copy, - sys::page_size(), - 0, - ) - }; - if let Some(err) = Maybe::::errno_sys_p( - written, - sys::Tag::copy_file_range, - dest.as_bytes(), - ) { - match err.get_errno() { - // EINVAL: eCryptfs and other filesystems may not support copy_file_range - // XDEV: cross-device copy not supported - // NOSYS: syscall not available - // OPNOTSUPP: filesystem doesn't support this operation - E::EXDEV | E::ENOSYS | E::EINVAL | E::EOPNOTSUPP => { - if matches!(err.get_errno(), E::ENOSYS | E::EOPNOTSUPP) { - sys::copy_file::disable_copy_file_range_syscall(); - } - let mut w = wrote.get(); - let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback(src, dest, src_fd, dest_fd, size, &mut w); - wrote.set(w); - return r; - } - _ => return err, - } - } - // wrote zero bytes means EOF - if written == 0 { - break; - } - wrote.set(wrote.get().saturating_add(written as u64)); - } - } else { - while size > 0 { - // Linux Kernel 5.3 or later - // Not supported in gVisor - // SAFETY: src_fd/dest_fd are valid open fds; copy_file_range is the libc FFI - let written = unsafe { - sys::linux::copy_file_range( - src_fd.native(), - &raw mut off_in_copy, - dest_fd.native(), - &raw mut off_out_copy, - size, - 0, - ) - }; - if let Some(err) = Maybe::::errno_sys_p( - written, - sys::Tag::copy_file_range, - dest.as_bytes(), - ) { - match err.get_errno() { - // EINVAL: eCryptfs and other filesystems may not support copy_file_range - // XDEV: cross-device copy not supported - // NOSYS: syscall not available - // OPNOTSUPP: filesystem doesn't support this operation - E::EXDEV | E::ENOSYS | E::EINVAL | E::EOPNOTSUPP => { - if matches!(err.get_errno(), E::ENOSYS | E::EOPNOTSUPP) { - sys::copy_file::disable_copy_file_range_syscall(); - } - let mut w = wrote.get(); - let r = Self::copy_file_using_sendfile_on_linux_with_read_write_fallback(src, dest, src_fd, dest_fd, size, &mut w); - wrote.set(w); - return r; - } - _ => return err, - } - } - // wrote zero bytes means EOF - if written == 0 { - break; - } - wrote.set(wrote.get().saturating_add(written as u64)); - size = size.saturating_sub(written as usize); - } - } - - return Ok(()); } #[cfg(target_os = "freebsd")] @@ -10070,8 +9932,26 @@ fn zig_delete_tree_min_stack_size_with_kind_hint( // ────────────────────────────────────────────────────────────────────────── // NodeFSFunctionEnum — one variant per NodeFS method // ────────────────────────────────────────────────────────────────────────── -#[derive(Copy, Clone, PartialEq, Eq, core::marker::ConstParamTy)] -pub enum NodeFSFunctionEnum { +/// Declares the enum and derives each variant's `"AsyncTask"` heap +/// label from the same list (Rust has no `type_name::()` in `const`, so +/// the label is keyed off the `F` discriminant — each `F` is bound to exactly +/// one `args::*` type via `async_::*`). +macro_rules! node_fs_function_enum { + ($($v:ident),+ $(,)?) => { + #[derive(Copy, Clone, PartialEq, Eq, core::marker::ConstParamTy)] + pub enum NodeFSFunctionEnum { + $($v,)+ + } + + impl NodeFSFunctionEnum { + pub const fn heap_label(self) -> &'static str { + match self { $(Self::$v => concat!("Async", stringify!($v), "Task"),)+ } + } + } + }; +} + +node_fs_function_enum!( Access, AppendFile, Chmod, @@ -10113,7 +9993,7 @@ pub enum NodeFSFunctionEnum { Write, WriteFile, Writev, -} +); impl NodeFSFunctionEnum { /// Maps each async-FS function to its event-loop [`TaskTag`] (the `tags!` @@ -10165,55 +10045,6 @@ impl NodeFSFunctionEnum { } } - /// Heap label `"AsyncTask"`. Rust has no - /// `type_name::()` in `const`, so key off the `F` discriminant - /// (each `F` is bound to exactly one `args::*` type via `async_::*`). - pub const fn heap_label(self) -> &'static str { - macro_rules! lbl { ($($v:ident),+ $(,)?) => { match self { $(Self::$v => concat!("Async", stringify!($v), "Task"),)+ } } } - lbl!( - Access, - AppendFile, - Chmod, - Chown, - Close, - CopyFile, - Exists, - Fchmod, - Fchown, - Fdatasync, - Fstat, - Fsync, - Ftruncate, - Futimes, - Lchmod, - Lchown, - Link, - Lstat, - Lutimes, - Mkdir, - Mkdtemp, - Open, - Read, - Readdir, - ReadFile, - Readlink, - Readv, - Realpath, - RealpathNonNative, - Rename, - Rm, - Rmdir, - Stat, - Statfs, - Symlink, - Truncate, - Unlink, - Utimes, - Write, - WriteFile, - Writev - ) - } pub const fn heap_label_uv(self) -> &'static str { match self { Self::Open => "AsyncOpenUvTask", diff --git a/src/runtime/node/path.rs b/src/runtime/node/path.rs index 112e1eea9dc..abd2bf5ba98 100644 --- a/src/runtime/node/path.rs +++ b/src/runtime/node/path.rs @@ -328,119 +328,32 @@ pub(crate) fn get_cwd_t(buf: &mut [T]) -> MaybeBuf<'_, T> { // Alias for naming consistency. pub use get_cwd_u8 as get_cwd; -/// Based on Node v21.6.1 path.posix.basename: -/// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L1309 -pub fn basename_posix_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T]>) -> &'a [T] { - // validateString of `path` is performed in pub fn basename. - let len = path.len(); - // Exit early for easier number type use. - if len == 0 { - return &[]; - } - let mut start: usize = 0; - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut end: Option = None; - let mut matched_slash: bool = true; - - let _suffix: &[T] = suffix.unwrap_or(&[]); - let _suffix_len = _suffix.len(); - if suffix.is_some() && _suffix_len > 0 && _suffix_len <= len { - if _suffix == path { - return &[]; - } - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut ext_idx: Option = Some(_suffix_len - 1); - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut first_non_slash_end: Option = None; - let mut i_i64 = i64::try_from(len - 1).expect("int cast"); - while i_i64 >= i64::try_from(start).expect("int cast") { - let i = usize::try_from(i_i64).expect("int cast"); - let byte = path[i]; - if byte == T::from_u8(CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if !matched_slash { - start = i + 1; - break; - } - } else { - if first_non_slash_end.is_none() { - // We saw the first non-path separator, remember this index in case - // we need it if the extension ends up not matching - matched_slash = false; - first_non_slash_end = Some(i + 1); - } - if let Some(_ext_ix) = ext_idx { - // Try to match the explicit extension - if byte == _suffix[_ext_ix] { - if _ext_ix == 0 { - // We matched the extension, so mark this as the end of our path - // component - end = Some(i); - ext_idx = None; - } else { - ext_idx = Some(_ext_ix - 1); - } - } else { - // Extension does not match, so our result is the entire path - // component - ext_idx = None; - end = first_non_slash_end; - } - } - } - i_i64 -= 1; - } - - if let Some(_end) = end { - if start == _end { - return &path[start..first_non_slash_end.unwrap()]; - } else { - return &path[start.._end]; - } - } - return &path[start..len]; - } - - let mut i_i64 = i64::try_from(len - 1).expect("int cast"); - while i_i64 > -1 { - let i = usize::try_from(i_i64).expect("int cast"); - let byte = path[i]; - if byte == T::from_u8(CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if !matched_slash { - start = i + 1; - break; - } - } else if end.is_none() { - // We saw the first non-path separator, mark this as the end of our - // path component - matched_slash = false; - end = Some(i + 1); - } - i_i64 -= 1; - } - - if let Some(_end) = end { - &path[start.._end] +/// `path.win32` treats both `/` and `\` as separators; `path.posix` only `/`. +/// `IS_WINDOWS` is const so the check monomorphizes to the exact comparison +/// the split posix/win32 functions used. +#[inline(always)] +fn is_sep_t(byte: T) -> bool { + if IS_WINDOWS { + is_sep_windows_t(byte) } else { - &[] + byte == T::from_u8(CHAR_FORWARD_SLASH) } } -/// Based on Node v21.6.1 path.win32.basename: +/// Based on Node v21.6.1 path.posix.basename / path.win32.basename, which +/// differ only in the separator set and the win32 drive-letter prologue: +/// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L1309 /// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L753 -pub fn basename_windows_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T]>) -> &'a [T] { +fn basename_t<'a, T: PathCharCwd, const IS_WINDOWS: bool>( + path: &'a [T], + suffix: Option<&[T]>, +) -> &'a [T] { // validateString of `path` is performed in pub fn basename. let len = path.len(); // Exit early for easier number type use. if len == 0 { return &[]; } - - let is_sep_t = is_sep_windows_t::; - let mut start: usize = 0; // We use an optional value instead of -1, as in Node code, for easier number type use. let mut end: Option = None; @@ -449,7 +362,11 @@ pub fn basename_windows_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T] // Check for a drive letter prefix so as not to mistake the following // path separator as an extra separator at the end of the path that can be // disregarded - if len >= 2 && is_windows_device_root_t(path[0]) && path[1] == T::from_u8(CHAR_COLON) { + if IS_WINDOWS + && len >= 2 + && is_windows_device_root_t(path[0]) + && path[1] == T::from_u8(CHAR_COLON) + { start = 2; } @@ -467,7 +384,7 @@ pub fn basename_windows_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T] while i_i64 >= i64::try_from(start).expect("int cast") { let i = usize::try_from(i_i64).expect("int cast"); let byte = path[i]; - if is_sep_t(byte) { + if is_sep_t::(byte) { // If we reached a path separator that was not part of a set of path // separators at the end of the string, stop now if !matched_slash { @@ -517,12 +434,16 @@ pub fn basename_windows_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T] while i_i64 >= i64::try_from(start).expect("int cast") { let i = usize::try_from(i_i64).expect("int cast"); let byte = path[i]; - if is_sep_t(byte) { + if is_sep_t::(byte) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now if !matched_slash { start = i + 1; break; } } else if end.is_none() { + // We saw the first non-path separator, mark this as the end of our + // path component matched_slash = false; end = Some(i + 1); } @@ -536,6 +457,14 @@ pub fn basename_windows_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T] } } +pub fn basename_posix_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T]>) -> &'a [T] { + basename_t::(path, suffix) +} + +pub fn basename_windows_t<'a, T: PathCharCwd>(path: &'a [T], suffix: Option<&[T]>) -> &'a [T] { + basename_t::(path, suffix) +} + pub fn basename_posix_js_t( global_object: &JSGlobalObject, path: &[T], @@ -812,32 +741,39 @@ pub(crate) fn dirname( dirname_js_t::(global_object, is_windows, path_zslice.slice()) } -/// Based on Node v21.6.1 path.posix.extname: -/// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L1388 -pub(crate) fn extname_posix_t(path: &[T]) -> &[T] { - // validateString of `path` is performed in pub fn extname. - let len = path.len(); - // Exit early for easier number type use. - if len == 0 { - return &[]; - } - // We use an optional value instead of -1, as in Node code, for easier number type use. +/// Result of [`scan_last_component_t`]. +struct LastComponentScan { + // We use optional values instead of -1, as in Node code, for easier number type use. + start_dot: Option, + start_part: usize, + end: Option, + pre_dot_state: Option, +} + +/// Reverse scan over the last path component, tracking dot positions. Node +/// repeats this loop verbatim in both the posix and win32 variants of +/// `extname` and `parse`; all four call sites share this copy. +/// +/// `path` must be non-empty; indices below `lower_bound` are not scanned. +fn scan_last_component_t( + path: &[T], + lower_bound: usize, + initial_start_part: usize, +) -> LastComponentScan { let mut start_dot: Option = None; - let mut start_part: usize = 0; - // We use an optional value instead of -1, as in Node code, for easier number type use. + let mut start_part = initial_start_part; let mut end: Option = None; - let mut matched_slash: bool = true; + let mut matched_slash = true; + // Track the state of characters (if any) we see before our first dot and // after any path separator we find - - // We use an optional value instead of -1, as in Node code, for easier number type use. let mut pre_dot_state: Option = Some(0); - let mut i_i64 = i64::try_from(len - 1).expect("int cast"); - while i_i64 > -1 { + let mut i_i64 = i64::try_from(path.len() - 1).expect("int cast"); + while i_i64 >= i64::try_from(lower_bound).expect("int cast") { let i = usize::try_from(i_i64).expect("int cast"); let byte = path[i]; - if byte == T::from_u8(CHAR_FORWARD_SLASH) { + if is_sep_t::(byte) { // If we reached a path separator that was not part of a set of path // separators at the end of the string, stop now if !matched_slash { @@ -847,20 +783,20 @@ pub(crate) fn extname_posix_t(path: &[T]) -> &[T] { i_i64 -= 1; continue; } - if end.is_none() { // We saw the first non-path separator, mark this as the end of our // extension matched_slash = false; end = Some(i + 1); } - if byte == T::from_u8(CHAR_DOT) { // If this is our first dot, mark it as the start of our extension if start_dot.is_none() { start_dot = Some(i); - } else if pre_dot_state.is_some() && pre_dot_state.unwrap() != 1 { - pre_dot_state = Some(1); + } else if let Some(_pre_dot_state) = pre_dot_state { + if _pre_dot_state != 1 { + pre_dot_state = Some(1); + } } } else if start_dot.is_some() { // We saw a non-dot and non-path separator before our dot, so we should @@ -870,25 +806,19 @@ pub(crate) fn extname_posix_t(path: &[T]) -> &[T] { i_i64 -= 1; } - let _end = end.unwrap_or(0); - let _pre_dot_state = pre_dot_state.unwrap_or(0); - let _start_dot = start_dot.unwrap_or(0); - if start_dot.is_none() - || end.is_none() - // We saw a non-dot character immediately before the dot - || (pre_dot_state.is_some() && _pre_dot_state == 0) - // The (right-most) trimmed path component is exactly '..' - || (_pre_dot_state == 1 && _start_dot == _end - 1 && _start_dot == start_part + 1) - { - return &[]; + LastComponentScan { + start_dot, + start_part, + end, + pre_dot_state, } - - &path[_start_dot.._end] } -/// Based on Node v21.6.1 path.win32.extname: +/// Based on Node v21.6.1 path.posix.extname / path.win32.extname, which differ +/// only in the separator set and the win32 drive-letter prologue: +/// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L1388 /// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L840 -pub(crate) fn extname_windows_t(path: &[T]) -> &[T] { +fn extname_t(path: &[T]) -> &[T] { // validateString of `path` is performed in pub fn extname. let len = path.len(); // Exit early for easier number type use. @@ -896,63 +826,24 @@ pub(crate) fn extname_windows_t(path: &[T]) -> &[T] { return &[]; } let mut start: usize = 0; - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut start_dot: Option = None; - let mut start_part: usize = 0; - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut end: Option = None; - let mut matched_slash: bool = true; - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut pre_dot_state: Option = Some(0); // Check for a drive letter prefix so as not to mistake the following // path separator as an extra separator at the end of the path that can be // disregarded - - if len >= 2 && path[1] == T::from_u8(CHAR_COLON) && is_windows_device_root_t(path[0]) { + if IS_WINDOWS + && len >= 2 + && path[1] == T::from_u8(CHAR_COLON) + && is_windows_device_root_t(path[0]) + { start = 2; - start_part = start; } - let mut i_i64 = i64::try_from(len - 1).expect("int cast"); - while i_i64 >= i64::try_from(start).expect("int cast") { - let i = usize::try_from(i_i64).expect("int cast"); - let byte = path[i]; - if is_sep_windows_t(byte) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if !matched_slash { - start_part = i + 1; - break; - } - i_i64 -= 1; - continue; - } - if end.is_none() { - // We saw the first non-path separator, mark this as the end of our - // extension - matched_slash = false; - end = Some(i + 1); - } - if byte == T::from_u8(CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if start_dot.is_none() { - start_dot = Some(i); - } else if let Some(_pre_dot_state) = pre_dot_state { - if _pre_dot_state != 1 { - pre_dot_state = Some(1); - } - } - } else if start_dot.is_some() { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - pre_dot_state = None; - } - i_i64 -= 1; - } + let LastComponentScan { + start_dot, + start_part, + end, + pre_dot_state, + } = scan_last_component_t::(path, start, start); let _end = end.unwrap_or(0); let _pre_dot_state = pre_dot_state.unwrap_or(0); @@ -970,6 +861,14 @@ pub(crate) fn extname_windows_t(path: &[T]) -> &[T] { &path[_start_dot.._end] } +pub(crate) fn extname_posix_t(path: &[T]) -> &[T] { + extname_t::(path) +} + +pub(crate) fn extname_windows_t(path: &[T]) -> &[T] { + extname_t::(path) +} + pub use bun_paths::is_sep_posix_t; // Node `path.win32.isPathSeparator` accepts BOTH `/` and `\` — semantically // `is_sep_any_t`, NOT `is_sep_win32_t` (which is `\`-only). Keep the Node name. @@ -2045,56 +1944,13 @@ pub fn parse_posix_t(path: &[T]) -> PathParsed<'_, T> { start = 1; } - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut start_dot: Option = None; - let mut start_part: usize = 0; - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut end: Option = None; - let mut matched_slash = true; - let mut i_i64 = i64::try_from(len - 1).expect("int cast"); - - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut pre_dot_state: Option = Some(0); - // Get non-dir info - while i_i64 >= i64::try_from(start).expect("int cast") { - let i = usize::try_from(i_i64).expect("int cast"); - let byte = path[i]; - if byte == T::from_u8(CHAR_FORWARD_SLASH) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if !matched_slash { - start_part = i + 1; - break; - } - i_i64 -= 1; - continue; - } - if end.is_none() { - // We saw the first non-path separator, mark this as the end of our - // extension - matched_slash = false; - end = Some(i + 1); - } - if byte == T::from_u8(CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if start_dot.is_none() { - start_dot = Some(i); - } else if let Some(_pre_dot_state) = pre_dot_state { - if _pre_dot_state != 1 { - pre_dot_state = Some(1); - } - } - } else if start_dot.is_some() { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - pre_dot_state = None; - } - i_i64 -= 1; - } + let LastComponentScan { + start_dot, + start_part, + end, + pre_dot_state, + } = scan_last_component_t::(path, start, 0); if let Some(_end) = end { let _pre_dot_state = pre_dot_state.unwrap_or(0); @@ -2159,7 +2015,7 @@ pub fn parse_windows_t(path: &[T]) -> PathParsed<'_, T> { let is_sep_t = is_sep_windows_t::; let mut root_end: usize = 0; - let mut byte = path[0]; + let byte = path[0]; if len == 1 { if is_sep_t(byte) { @@ -2254,56 +2110,13 @@ pub fn parse_windows_t(path: &[T]) -> PathParsed<'_, T> { root = &path[0..root_end]; } - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut start_dot: Option = None; - let mut start_part = root_end; - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut end: Option = None; - let mut matched_slash = true; - let mut i_i64 = i64::try_from(len - 1).expect("int cast"); - - // Track the state of characters (if any) we see before our first dot and - // after any path separator we find - - // We use an optional value instead of -1, as in Node code, for easier number type use. - let mut pre_dot_state: Option = Some(0); - // Get non-dir info - while i_i64 >= i64::try_from(root_end).expect("int cast") { - let i = usize::try_from(i_i64).expect("int cast"); - byte = path[i]; - if is_sep_t(byte) { - // If we reached a path separator that was not part of a set of path - // separators at the end of the string, stop now - if !matched_slash { - start_part = i + 1; - break; - } - i_i64 -= 1; - continue; - } - if end.is_none() { - // We saw the first non-path separator, mark this as the end of our - // extension - matched_slash = false; - end = Some(i + 1); - } - if byte == T::from_u8(CHAR_DOT) { - // If this is our first dot, mark it as the start of our extension - if start_dot.is_none() { - start_dot = Some(i); - } else if let Some(_pre_dot_state) = pre_dot_state { - if _pre_dot_state != 1 { - pre_dot_state = Some(1); - } - } - } else if start_dot.is_some() { - // We saw a non-dot and non-path separator before our dot, so we should - // have a good chance at having a non-empty extension - pre_dot_state = None; - } - i_i64 -= 1; - } + let LastComponentScan { + start_dot, + start_part, + end, + pre_dot_state, + } = scan_last_component_t::(path, root_end, root_end); if let Some(_end) = end { let _pre_dot_state = pre_dot_state.unwrap_or(0); diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index a5979c4335b..e0f41b7e48e 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -439,20 +439,7 @@ impl StringOrBuffer { Ok(true) } - JSType::ArrayBuffer - | JSType::Int8Array - | JSType::Uint8Array - | JSType::Uint8ClampedArray - | JSType::Int16Array - | JSType::Uint16Array - | JSType::Int32Array - | JSType::Uint32Array - | JSType::Float32Array - | JSType::Float16Array - | JSType::Float64Array - | JSType::BigInt64Array - | JSType::BigUint64Array - | JSType::DataView => { + t if t.is_array_buffer_like() => { let buffer = if is_async { Buffer::from_js_pinned(global, value) .unwrap_or_else(|| Buffer::from_array_buffer(global, value)) diff --git a/test/js/node/fs/fs-chown-utimes.test.ts b/test/js/node/fs/fs-chown-utimes.test.ts new file mode 100644 index 00000000000..0fc58ff0f7b --- /dev/null +++ b/test/js/node/fs/fs-chown-utimes.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { isWindows, tmpdirSync } from "harness"; +import fs from "node:fs"; +import { join } from "node:path"; + +// chown/fchown/lchown share one uid/gid reader and utimes/futimes/lutimes +// share one atime/mtime reader in src/runtime/node/node_fs.rs, so every +// sibling must validate identically and name the failing argument itself. + +describe.concurrent("chown/fchown/lchown argument validation", () => { + it("validates uid and gid to [-1, 2**32 - 1] with the argument's own name", () => { + const tmp = join(tmpdirSync(), "chown-args.txt"); + fs.writeFileSync(tmp, "x"); + const fd = fs.openSync(tmp, "r+"); + try { + const variants: ((uid: any, gid: any) => void)[] = [ + (uid, gid) => fs.chownSync(tmp, uid, gid), + (uid, gid) => fs.fchownSync(fd, uid, gid), + (uid, gid) => fs.lchownSync(tmp, uid, gid), + ]; + for (const call of variants) { + expect(() => call(-2, 0)).toThrow( + RangeError('The value of "uid" is out of range. It must be >= -1 and <= 4294967295. Received -2'), + ); + expect(() => call(0, 2 ** 32)).toThrow( + RangeError('The value of "gid" is out of range. It must be >= -1 and <= 4294967295. Received 4294967296'), + ); + expect(() => call(1.5, 0)).toThrow( + RangeError('The value of "uid" is out of range. It must be an integer. Received 1.5'), + ); + expect(() => call(0, "a")).toThrow( + TypeError("The \"gid\" argument must be of type number. Received type string ('a')"), + ); + expect(() => call(0, "a")).toThrowWithCode(TypeError, "ERR_INVALID_ARG_TYPE"); + } + // -1 ("leave unchanged") and the u32 maximum are both in range. lchown + // is unimplemented on Windows and fails after argument validation + // (#32050), so only chown/fchown exercise the success path there. + const implemented = isWindows ? variants.slice(0, 2) : variants; + for (const call of implemented) { + expect(() => call(-1, -1)).not.toThrow(); + expect(() => call(2 ** 32 - 1, 2 ** 32 - 1)).not.toThrow(); + } + } finally { + fs.closeSync(fd); + } + }); +}); + +describe.concurrent("utimes/futimes/lutimes argument validation", () => { + it("rejects non-finite and non-number atime/mtime with the argument's own name", () => { + const tmp = join(tmpdirSync(), "utimes-args.txt"); + fs.writeFileSync(tmp, "x"); + const fd = fs.openSync(tmp, "r+"); + try { + const variants: ((atime: any, mtime: any) => void)[] = [ + (atime, mtime) => fs.utimesSync(tmp, atime, mtime), + (atime, mtime) => fs.futimesSync(fd, atime, mtime), + (atime, mtime) => fs.lutimesSync(tmp, atime, mtime), + ]; + for (const call of variants) { + expect(() => call(0, 0)).not.toThrow(); + expect(() => call(new Date(), new Date())).not.toThrow(); + for (const bad of [{}, NaN, Infinity, -Infinity]) { + expect(() => call(bad, 0)).toThrow(TypeError("atime must be a number or a Date")); + expect(() => call(0, bad)).toThrow(TypeError("mtime must be a number or a Date")); + } + expect(() => call({}, 0)).toThrowWithCode(TypeError, "ERR_INVALID_ARG_TYPE"); + } + } finally { + fs.closeSync(fd); + } + }); + + it("utimesSync follows symlinks and lutimesSync does not", () => { + const dir = tmpdirSync(); + const target = join(dir, "target.txt"); + const link = join(dir, "link"); + fs.writeFileSync(target, "x"); + fs.symlinkSync(target, link); + + const linkTime = new Date("2000-01-02T03:04:05.000Z"); + fs.lutimesSync(link, linkTime, linkTime); + const targetTime = new Date("2010-06-07T08:09:10.000Z"); + fs.utimesSync(link, targetTime, targetTime); + + expect({ + link: fs.lstatSync(link).mtime.toISOString(), + target: fs.statSync(target).mtime.toISOString(), + }).toEqual({ + link: linkTime.toISOString(), + target: targetTime.toISOString(), + }); + }); +});