diff --git a/bench/fs-cp/cp.mjs b/bench/fs-cp/cp.mjs index 4a9f6fd436fc..98314c4e2f62 100644 --- a/bench/fs-cp/cp.mjs +++ b/bench/fs-cp/cp.mjs @@ -3,9 +3,10 @@ // bun cp.mjs // node cp.mjs // -// The "regular files only" trees are eligible for the whole-tree clonefile() -// fast path on macOS; the trees containing a symlink always go through the -// node-ported walker. +// The "regular files only" trees take the native copy (thread pool, or one +// whole-tree clonefile() on macOS). The trees containing a symlink take it on +// Linux only. macOS sends them through the node-ported walker, and Windows +// sends every tree through it. A `filter` always means the walker. import { cpSync, mkdirSync, promises, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -72,6 +73,13 @@ recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, regular files recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, tree contains a symlink)`, dest => promises.cp(symlinkSrc, dest, { recursive: true }), ); +recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, destination is an empty directory)`, dest => { + mkdirSync(dest); + return promises.cp(plainSrc, dest, { recursive: true }); +}); +recursiveCopyBench(`fs.promises.cp recursive (${totalFiles} files, filter)`, dest => + promises.cp(plainSrc, dest, { recursive: true, filter: () => true }), +); try { await run(); diff --git a/src/js/internal/fs/cp-sync.ts b/src/js/internal/fs/cp-sync.ts index be4de692fcbb..77dcc99b3750 100644 --- a/src/js/internal/fs/cp-sync.ts +++ b/src/js/internal/fs/cp-sync.ts @@ -19,6 +19,7 @@ const { const { dirname, isAbsolute, join, parse, resolve, sep } = require("node:path"); const { EEXIST, EISDIR, EINVAL, ENOTDIR } = $processBindingConstants.os.errno; +const { COPYFILE_FICLONE_FORCE } = $processBindingConstants.fs; const ArrayPrototypeEvery = Array.prototype.every; const ArrayPrototypeFilter = Array.prototype.filter; @@ -247,35 +248,62 @@ function checkParentPathsSync(src, srcStat, dest) { return checkParentPathsSync(src, srcStat, destParent); } -// The native recursive copy (a single clonefile() on macOS) copies symlinks -// verbatim and clones special files, while node rewrites relative symlink -// targets against the source tree and raises ERR_FS_CP_SOCKET / -// ERR_FS_CP_FIFO_PIPE. It is therefore only node-equivalent for trees made of -// regular files and directories; anything else — including entries whose type -// the filesystem does not report — bails to the ported walker. Scan errors -// also bail so the walker surfaces them the way node would. -function treeContainsOnlyFilesAndDirsSync(root) { - const stack = [root]; - while (stack.length) { - const dir = stack.pop(); - let entries; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return false; - } - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (entry.isDirectory()) { - stack.push(join(dir, entry.name)); - } else if (!entry.isFile()) { +// The native copy ignores `mode`, and only COPYFILE_FICLONE_FORCE changes the result (it must fail without a clone). +function nativeHonorsOptions(opts) { + return ( + !opts.filter && + !opts.dereference && + !opts.preserveTimestamps && + !opts.verbatimSymlinks && + (opts.mode & COPYFILE_FICLONE_FORCE) === 0 + ); +} + +// The native tree copy on Windows fails on paths longer than MAX_PATH. +const nativeCopiesTrees = process.platform !== "win32"; + +// Elsewhere the native copy rewrites a relative link target against the source tree, like node. +const nativeResolvesSymlinks = process.platform !== "darwin" && process.platform !== "win32"; + +// The native copy recurses once per directory level on a 4 MB thread stack. +const kNativeMaxDepth = 64; + +// False for a tree the walker has to copy: special files (node's ERR_FS_CP_* errors), scan errors, too deep. +function nativeCanCopyTreeSync(root) { + let dirs = [root]; + for (let depth = 0; dirs.length; depth++) { + if (depth > kNativeMaxDepth) return false; + const next = []; + for (let d = 0; d < dirs.length; d++) { + const dir = dirs[d]; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { return false; } + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.isDirectory()) { + next.push(join(dir, entry.name)); + } else if (!entry.isFile() && !(nativeResolvesSymlinks && entry.isSymbolicLink())) { + return false; + } + } } + dirs = next; } return true; } +function isEmptyDirSync(path) { + try { + return readdirSync(path).length === 0; + } catch { + return false; + } +} + // node-correct validation before handing off to the native fast path // (which performs the copy but does not implement node's cp error codes). function tryNativeFastPathSync(src, dest, opts) { @@ -292,20 +320,18 @@ function tryNativeFastPathSync(src, dest, opts) { }); } if (srcStat.isDirectory()) { - // On macOS the native path clones the whole tree with a single - // clonefile(). Only take it when the result is indistinguishable from - // node's walker: dest must not exist (no merge semantics) and the tree - // must contain only regular files and directories. - return { - ok: process.platform === "darwin" && !destStat && treeContainsOnlyFilesAndDirsSync(src), - checked, - }; + // node rejects an existing dest directory, even an empty one, for `errorOnExist` without `force`. + const nothingToMerge = !destStat || (!(opts.errorOnExist && !opts.force) && isEmptyDirSync(dest)); + return { ok: nativeCopiesTrees && nothingToMerge && nativeCanCopyTreeSync(src), checked }; } // The single-file native copy is only node-equivalent for regular-file -> // regular-file (or missing dest). Symlinks (node resolves relative link // targets) and special files (node-specific error codes) must go through // the ported implementation. - return { ok: srcStat.isFile() && (!destStat || destStat.isFile()), checked }; + if (!srcStat.isFile()) return { ok: false, checked }; + // node unlinks an existing dest first and the native copy overwrites it in place: default options only, as before. + const overwrites = opts.force && !opts.errorOnExist && opts.mode === 0; + return { ok: !destStat || (overwrites && destStat.isFile()), checked }; } function cpSyncFn(src, dest, opts, checked?) { @@ -523,4 +549,8 @@ export default { fsEisdirError, areIdentical, isSrcSubdir, + kNativeMaxDepth, + nativeCopiesTrees, + nativeHonorsOptions, + nativeResolvesSymlinks, }; diff --git a/src/js/internal/fs/cp.ts b/src/js/internal/fs/cp.ts index f7db620f191e..31a18ba035ec 100644 --- a/src/js/internal/fs/cp.ts +++ b/src/js/internal/fs/cp.ts @@ -12,6 +12,9 @@ const { fsEisdirError, areIdentical, isSrcSubdir, + kNativeMaxDepth, + nativeCopiesTrees, + nativeResolvesSymlinks, } = require("internal/fs/cp-sync"); const { @@ -119,35 +122,50 @@ async function checkParentPaths(src, srcStat, dest) { return checkParentPaths(src, srcStat, destParent); } -// The native recursive copy (a single clonefile() on macOS) copies symlinks -// verbatim and clones special files, while node rewrites relative symlink -// targets against the source tree and raises ERR_FS_CP_SOCKET / -// ERR_FS_CP_FIFO_PIPE. It is therefore only node-equivalent for trees made of -// regular files and directories; anything else — including entries whose type -// the filesystem does not report — bails to the ported walker. Scan errors -// also bail so the walker surfaces them the way node would. -async function treeContainsOnlyFilesAndDirs(root) { - const stack = [root]; - while (stack.length) { - const dir = stack.pop(); - let entries; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return false; - } - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - if (entry.isDirectory()) { - stack.push(join(dir, entry.name)); - } else if (!entry.isFile()) { +const kScanConcurrency = 64; + +// False for a tree the walker has to copy: special files (node's ERR_FS_CP_* errors), scan errors, too deep. +async function nativeCanCopyTree(root) { + let dirs = [root]; + for (let depth = 0; dirs.length; depth++) { + if (depth > kNativeMaxDepth) return false; + const next = []; + for (let start = 0; start < dirs.length; start += kScanConcurrency) { + const pending = []; + for (let d = start; d < dirs.length && d < start + kScanConcurrency; d++) { + pending.push(readdir(dirs[d], { withFileTypes: true })); + } + let lists; + try { + lists = await Promise.all(pending); + } catch { return false; } + for (let d = 0; d < lists.length; d++) { + const entries = lists[d]; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (entry.isDirectory()) { + next.push(join(dirs[start + d], entry.name)); + } else if (!entry.isFile() && !(nativeResolvesSymlinks && entry.isSymbolicLink())) { + return false; + } + } + } } + dirs = next; } return true; } +async function isEmptyDir(path) { + try { + return (await readdir(path)).length === 0; + } catch { + return false; + } +} + // node-correct validation before handing off to the native fast path // (which performs the copy but does not implement node's cp error codes). async function tryNativeFastPath(src, dest, opts) { @@ -164,20 +182,18 @@ async function tryNativeFastPath(src, dest, opts) { }); } if (srcStat.isDirectory()) { - // On macOS the native path clones the whole tree with a single - // clonefile(). Only take it when the result is indistinguishable from - // node's walker: dest must not exist (no merge semantics) and the tree - // must contain only regular files and directories. - return { - ok: process.platform === "darwin" && !destStat && (await treeContainsOnlyFilesAndDirs(src)), - checked, - }; + // node rejects an existing dest directory, even an empty one, for `errorOnExist` without `force`. + const nothingToMerge = !destStat || (!(opts.errorOnExist && !opts.force) && (await isEmptyDir(dest))); + return { ok: nativeCopiesTrees && nothingToMerge && (await nativeCanCopyTree(src)), checked }; } // The single-file native copy is only node-equivalent for regular-file -> // regular-file (or missing dest). Symlinks (node resolves relative link // targets) and special files (node-specific error codes) must go through // the ported implementation. - return { ok: srcStat.isFile() && (!destStat || destStat.isFile()), checked }; + if (!srcStat.isFile()) return { ok: false, checked }; + // node unlinks an existing dest first and the native copy overwrites it in place: default options only, as before. + const overwrites = opts.force && !opts.errorOnExist && opts.mode === 0; + return { ok: !destStat || (overwrites && destStat.isFile()), checked }; } async function cpFn(src, dest, opts, checked?) { diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index 2c0bdf175caf..f488de7d1ea1 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -177,16 +177,17 @@ function watch( // and on MacOS, simple cases of recursive directory trees can be done in a single `clonefile()` // using filter and other options uses a lazily loaded js fallback ported from node.js async function cp(src, dest, options) { - const { validateCpOptions } = require("internal/fs/cp-sync"); + const { validateCpOptions, nativeHonorsOptions } = require("internal/fs/cp-sync"); const { getValidatedFsPath } = require("internal/validators"); options = validateCpOptions(options); src = getValidatedFsPath(src, "src"); dest = getValidatedFsPath(dest, "dest"); - const { filter, dereference, preserveTimestamps, verbatimSymlinks, mode, errorOnExist, force, recursive } = options; - if (!filter && !dereference && !preserveTimestamps && !verbatimSymlinks && !mode && !errorOnExist && force) { + if (nativeHonorsOptions(options)) { const { ok, checked } = await require("internal/fs/cp").tryNativeFastPath(src, dest, options); if (ok) { - return fs.cp(src, dest, recursive, errorOnExist, force, mode); + const { errorOnExist, force, recursive, mode } = options; + // node ignores `errorOnExist` when `force` is set. + return fs.cp(src, dest, recursive, errorOnExist && !force, force, mode); } return require("internal/fs/cp").cpFn(src, dest, options, checked); } diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 29433463bbc4..ac9e8d9feab1 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -968,16 +968,17 @@ realpathSync.native = fs.realpathNativeSync.bind(fs); // and on MacOS, simple cases of recursive directory trees can be done in a single `clonefile()` // using filter and other options uses a lazily loaded js fallback ported from node.js function cpSync(src, dest, options) { - const { cpSyncFn, validateCpOptions, tryNativeFastPathSync } = require("internal/fs/cp-sync"); + const { cpSyncFn, validateCpOptions, nativeHonorsOptions, tryNativeFastPathSync } = require("internal/fs/cp-sync"); const { getValidatedFsPath } = require("internal/validators"); options = validateCpOptions(options); src = getValidatedFsPath(src, "src"); dest = getValidatedFsPath(dest, "dest"); - const { filter, dereference, preserveTimestamps, verbatimSymlinks, mode, errorOnExist, force, recursive } = options; - if (!filter && !dereference && !preserveTimestamps && !verbatimSymlinks && !mode && !errorOnExist && force) { + if (nativeHonorsOptions(options)) { const { ok, checked } = tryNativeFastPathSync(src, dest, options); if (ok) { - return fs.cpSync(src, dest, recursive, errorOnExist, force, mode); + const { errorOnExist, force, recursive, mode } = options; + // node ignores `errorOnExist` when `force` is set. + return fs.cpSync(src, dest, recursive, errorOnExist && !force, force, mode); } return cpSyncFn(src, dest, options, checked); } diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index b36e7e2779e5..67e4e4785813 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -456,6 +456,38 @@ fn directory_exists_at_os_path(dir: FD, path: &OSPathSliceZ) -> Maybe { } } +/// The source directory's mode, for a directory `fs.cp` created. `None`: Windows `chmod` only sets read-only. +fn cp_created_dir_mode(src_dir: FD) -> Option { + let mode = sys::fstat(src_dir).ok()?.st_mode as Mode; + if cfg!(windows) && mode & (sys::S::IWUSR as Mode) != 0 { + return None; + } + Some(mode & 0o7777) +} + +/// Best effort, like the `fchmod` of a copied file: exFAT and some network mounts refuse `chmod`. +fn cp_chmod_created_dir(path: &OSPathSliceZ, mode: Mode) { + #[cfg(not(windows))] + let _ = Syscall::chmod(path, mode); + #[cfg(windows)] + { + let mut buf = paths::path_buffer_pool::get(); + let _ = Syscall::chmod(strings::from_wpath(&mut buf[..], path.as_slice()), mode); + } +} + +/// `d_type` is `DT_UNKNOWN` on some filesystems (FUSE, NFS, bind mounts). +#[cfg(not(windows))] +fn cp_entry_kind(dir: FD, entry: &DirIterator::IteratorResult) -> sys::FileKind { + if entry.kind != sys::FileKind::Unknown { + return entry.kind; + } + match sys::lstatat(dir, entry.name_assume_z()) { + Ok(st) => sys::kind_from_mode(st.st_mode as Mode), + Err(_) => entry.kind, + } +} + type ReadPosition = i64; type Buffer = super::types::Buffer; type GidT = node::gid_t; @@ -1371,6 +1403,8 @@ mod _async_tasks { /// enqueued once the count reaches zero, so subtasks still running on the /// thread pool never dereference a freed parent. pub(crate) subtask_count: AtomicUsize, + /// NUL-terminated `dest` and mode of each directory created; set last, so a mode without `w` fails no subtask. + created_dirs: bun_threading::Guarded, Mode)>>, /// BACKREF — `Some` iff `IS_SHELL`. The shell `ShellCpTask` owns and /// outlives this task; `ParentRef` gives a safe `&ShellCpTask` projection /// for `cp_on_copy` and round-trips the `*mut` for `cp_on_finish`. @@ -1464,11 +1498,16 @@ mod _async_tasks { 'brk: { match result { - Err(ref err) => { + Err(err) => { if err.errno == E::EEXIST as _ && !args.flags.error_on_exist { break 'brk; } - parent.finish_concurrently(result); + // The shell reads the failed operand from the error as it is. + parent.finish_concurrently(Err(if IS_SHELL { + err + } else { + node_fs.cp_entry_error(err, self.src(), self.dest()) + })); } Ok(_) => { parent.on_copy(self.src(), self.dest()); @@ -1577,6 +1616,7 @@ mod _async_tasks { r#ref: KeepAlive::default(), tracker, subtask_count: AtomicUsize::new(1), + created_dirs: bun_threading::Guarded::new(Vec::new()), // SAFETY: `shelltask` (when non-null) is the live heap-alloc'd `ShellCpTask` // that owns and outlives this task; pointer carries write provenance. shelltask: unsafe { bun_ptr::ParentRef::from_nullable_mut(shelltask) }, @@ -1619,6 +1659,14 @@ mod _async_tasks { self.result.set(result); } + /// Deepest first, the order node's walker sets them in. + fn chmod_created_dirs(&self) { + let dirs = core::mem::take(&mut *self.created_dirs.lock()); + for (dest, mode) in dirs.iter().rev() { + cp_chmod_created_dir(OSPathSliceZ::from_buf(dest, dest.len() - 1), *mode); + } + } + /// Called exactly once by the main directory-scan task and once by each /// `SingleTask` when it is done touching `this`. The last caller (count /// drops to zero) enqueues `runFromJSThread`, which resolves the promise @@ -1643,6 +1691,7 @@ mod _async_tasks { if !this_ref.has_result.load(Ordering::Relaxed) { this_ref.has_result.store(true, Ordering::Relaxed); // count reached zero ⇒ this thread now has exclusive access. + this_ref.chmod_created_dirs(); this_ref.result.set(Ok(())); } @@ -1840,7 +1889,11 @@ mod _async_tasks { } } this.on_copy(src, dest); - this.finish_concurrently(r); + this.finish_concurrently(if IS_SHELL { + r + } else { + r.map_err(|err| nodefs.cp_entry_error(err, src, dest)) + }); return; } } @@ -1880,7 +1933,11 @@ mod _async_tasks { } } this.on_copy(src, dest); - this.finish_concurrently(r); + this.finish_concurrently(if IS_SHELL { + r + } else { + r.map_err(|err| nodefs.cp_entry_error(err, src, dest)) + }); return; } } @@ -1945,7 +2002,7 @@ mod _async_tasks { src.as_bytes(), ) { match err.get_errno() { - E::EACCES | E::ENAMETOOLONG | E::EROFS | E::EPERM | E::EINVAL => { + E::ENAMETOOLONG | E::EROFS | E::EINVAL => { // `errno_sys_p` // already boxed `src.as_bytes()` into `err.path`, so just forward. this_ref.finish_concurrently(err); @@ -1995,7 +2052,12 @@ mod _async_tasks { #[cfg(not(windows))] let normdest: &OSPathSliceZ = dest; - let mkdir_ = nodefs.mkdir_recursive_os_path(normdest, args::Mkdir::DEFAULT_MODE, false); + let dest_created = CpDestDirCreated::new(normdest); + let mkdir_ = nodefs.mkdir_recursive_os_path_impl::<_, false>( + &dest_created, + normdest, + args::Mkdir::DEFAULT_MODE, + ); match mkdir_ { Err(err) => { this_ref.finish_concurrently(Err(err)); @@ -2005,6 +2067,18 @@ mod _async_tasks { this_ref.on_copy(src, normdest); } } + // The shell's `cp` keeps the mode `mkdir` gave the directory. + if !IS_SHELL && dest_created.get() { + if let Some(mode) = cp_created_dir_mode(fd) { + let mut owned = Vec::with_capacity(normdest.len() + 1); + owned.extend_from_slice(&normdest[..]); + owned.push(0); + this_ref + .created_dirs + .lock() + .push((owned.into_boxed_slice(), mode)); + } + } // On POSIX directory entries are always UTF-8, so monomorphise the // const-generic path type on `U8` and let the Windows branch (gated @@ -2046,7 +2120,11 @@ mod _async_tasks { return false; } - match current.kind { + #[cfg(windows)] + let kind = current.kind; + #[cfg(not(windows))] + let kind = cp_entry_kind(fd, ¤t); + match kind { crate::node::dirent::Kind::Directory => { let sd = src_dir_len as usize; let dd = dest_dir_len as usize; @@ -5453,19 +5531,6 @@ impl NodeFS { } } - pub(crate) fn mkdir_recursive_os_path( - &mut self, - path: &OSPathSliceZ, - mode: Mode, - return_path: bool, - ) -> Maybe { - if return_path { - self.mkdir_recursive_os_path_impl::<(), true>(&(), path, mode) - } else { - self.mkdir_recursive_os_path_impl::<(), false>(&(), path, mode) - } - } - pub(crate) fn mkdir_recursive_os_path_impl( &mut self, ctx: &Ctx, @@ -7920,6 +7985,31 @@ impl NodeFS { Self::os_path_into_buf(&mut self.sync_error_buf, slice) } + /// The error node's `cp` reports for an entry: the `lstat` failure if it cannot be reached, else `copyfile`. + fn cp_entry_error( + &mut self, + err: sys::Error, + src: &OSPathSliceZ, + dest: &OSPathSliceZ, + ) -> sys::Error { + if matches!(err.syscall, sys::Tag::readlink | sys::Tag::symlink) { + return err; + } + #[cfg(not(windows))] + if let Err(stat_err) = Syscall::lstat(src) { + return stat_err.with_path(src.as_bytes()); + } + let path = self.os_path_into_sync_error_buf(src).into(); + let dest = self.os_path_into_sync_error_buf(dest).into(); + sys::Error { + errno: err.errno, + syscall: sys::Tag::copyfile, + path, + dest, + ..Default::default() + } + } + /// Free-function form of [`os_path_into_sync_error_buf`] that does not borrow /// `&mut self`. Needed by `mkdir_recursive_os_path_impl`, which holds a long-lived /// `&mut OSPathBuffer` reinterpreted from `sync_error_buf` and so must not reborrow @@ -7985,7 +8075,7 @@ impl NodeFS { return Ok(()); } } - return r; + return r.map_err(|err| self.cp_entry_error(err, src, dest)); } } #[cfg(not(windows))] @@ -8014,7 +8104,7 @@ impl NodeFS { return Ok(()); } } - return r; + return r.map_err(|err| self.cp_entry_error(err, src, dest)); } } @@ -8028,7 +8118,7 @@ impl NodeFS { } #[cfg(target_os = "macos")] - 'try_with_clonefile: { + { // CLONE_NOFOLLOW: `src` was classified as a directory via lstat, so // mirror the O_NOFOLLOW directory open below instead of dereferencing. if let Some(err) = Maybe::::errno_sys_p( @@ -8037,10 +8127,7 @@ impl NodeFS { src.as_bytes(), ) { match err.get_errno() { - E::ENAMETOOLONG | E::EROFS | E::EINVAL | E::EACCES | E::EPERM => { - if matches!(err.get_errno(), E::EACCES | E::EPERM) && args.flags.force { - break 'try_with_clonefile; - } + E::ENAMETOOLONG | E::EROFS | E::EINVAL => { // `errno_sys_p` already boxed // `src.as_bytes()` into the inner `Error::path`, so just propagate. return err; @@ -8065,10 +8152,12 @@ impl NodeFS { }; let _close = scopeguard::guard(fd, |fd| fd.close()); - match self.mkdir_recursive_os_path(dest, args::Mkdir::DEFAULT_MODE, false) { - Err(err) => return Err(err), - Ok(_) => {} - } + let dest_created = CpDestDirCreated::new(dest); + self.mkdir_recursive_os_path_impl::<_, false>( + &dest_created, + dest, + args::Mkdir::DEFAULT_MODE, + )?; // The OSPathBuffer copy below is generic over `OSPathChar`, so on Windows // this needs the wide (u16) iterator; the u8 path is correct for POSIX. @@ -8109,7 +8198,11 @@ impl NodeFS { dest_buf[dd] = paths::SEP as OSPathChar; dest_buf[dd + 1 + name_slice.len()] = 0; - match current.kind { + #[cfg(windows)] + let kind = current.kind; + #[cfg(not(windows))] + let kind = cp_entry_kind(fd, ¤t); + match kind { sys::FileKind::Directory => { let r = self.cp_sync_inner( src_buf, @@ -8137,15 +8230,22 @@ impl NodeFS { None, args, ); - if let Err(ref e) = r { + if let Err(e) = r { if e.errno == E::EEXIST as _ && !cp_flags.error_on_exist { continue; } - return r; + return Err(self.cp_entry_error(e, src_z, dest_z)); } } } } + + if dest_created.get() { + if let Some(mode) = cp_created_dir_mode(fd) { + dest_buf[dd] = 0; + cp_chmod_created_dir(OSPathSliceZ::from_buf(&dest_buf[..], dd), mode); + } + } Ok(()) } @@ -8200,7 +8300,7 @@ impl NodeFS { // SAFETY: NUL written at `target_buf[link_len]`. let link_target = ZStr::from_buf(&target_buf[..], link_len); if paths::is_absolute(link_target.as_bytes()) { - return Syscall::symlink(link_target, dest); + return Self::cp_create_symlink(link_target, dest); } let mut cwd_buf = bun_paths::path_buffer_pool::get(); let mut resolved_buf = bun_paths::path_buffer_pool::get(); @@ -8208,7 +8308,7 @@ impl NodeFS { let Ok(cwd_len) = sys::getcwd(&mut cwd_buf[..]) else { // If we can't resolve cwd, preserve the link target as-is rather // than pointing the copied link back at the source path. - return Syscall::symlink(link_target, dest); + return Self::cp_create_symlink(link_target, dest); }; let cwd = &cwd_buf[..cwd_len]; let resolved_buf_len = resolved_buf.len(); @@ -8227,10 +8327,21 @@ impl NodeFS { ..Default::default() }); }; - let resolved_len = resolved.len(); + // node's `path.resolve()` leaves no trailing separator ("sub/" -> "/sub"). + let mut resolved_len = resolved.len(); + while resolved_len > 1 && resolved[resolved_len - 1] == b'/' { + resolved_len -= 1; + } resolved_buf[resolved_len] = 0; // SAFETY: NUL written at `resolved_buf[resolved_len]`. - Syscall::symlink(ZStr::from_buf(&resolved_buf[..], resolved_len), dest) + Self::cp_create_symlink(ZStr::from_buf(&resolved_buf[..], resolved_len), dest) + } + + /// `fs.symlink` reports the target as `path` and the link as `dest`. + #[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))] + fn cp_create_symlink(target: &ZStr, dest: &ZStr) -> Maybe { + Syscall::symlink(target, dest) + .map_err(|err| err.with_path_dest(target.as_bytes(), dest.as_bytes())) } /// This is `copyFile`, but it copies symlinks as-is @@ -9017,6 +9128,30 @@ pub(crate) trait MkdirCtx { } impl MkdirCtx for () {} +/// Whether a recursive mkdir created the directory it was asked for: node only sets the mode of those. +struct CpDestDirCreated { + len: usize, + created: core::cell::Cell, +} +impl CpDestDirCreated { + fn new(dest: &OSPathSliceZ) -> Self { + Self { + len: dest.len(), + created: core::cell::Cell::new(false), + } + } + fn get(&self) -> bool { + self.created.get() + } +} +impl MkdirCtx for CpDestDirCreated { + fn on_create_dir(&self, path: &OSPathSliceZ) { + if path.len() == self.len { + self.created.set(true); + } + } +} + /// Trait abstracting over the three readdir entry types. /// /// Rust can't switch on a generic `T` at runtime, so the per-type append diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index aedf9cf262d7..b541ef34ce65 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -817,6 +817,7 @@ pub trait PathLikeExt { fn slice_w<'a>(&'a self, buf: &'a mut WPathBuffer) -> Result<&'a WStr, NameTooLong> where Self: Sized; + /// Always materialized in `buf` (the `cp` walks append entry names to it), unlike `slice_z`. fn os_path<'a>(&'a self, buf: &'a mut OSPathBuffer) -> Result<&'a OSPathSliceZ, NameTooLong> where Self: Sized; @@ -997,7 +998,8 @@ impl PathLikeExt for PathLike<'_> { } #[cfg(not(windows))] { - Ok(self.slice_z_with_force_copy::(buf)) + // Unforced, an empty operand (`fs.cp(dir, "")`) yields `ZStr::EMPTY` and skips `buf`. + Ok(self.slice_z_with_force_copy::(buf)) } } diff --git a/test/cli/run/fuse-fs.py b/test/cli/run/fuse-fs.py index 61cc7339e251..1f4916e93373 100644 --- a/test/cli/run/fuse-fs.py +++ b/test/cli/run/fuse-fs.py @@ -2,7 +2,9 @@ # Used to ensure bun can run files mounted on FUSE # The filesystem will appear to have `main.js` containing: # console.log("hello world"); -# and `main-symlink.js` as a symlink to `main.js`. +# `main-symlink.js` as a symlink to `main.js`, +# and a directory `sub` that holds `nested.js`, a copy of `main.js`. +# readdir reports no entry types (d_type is DT_UNKNOWN). import fuse import errno, stat, os @@ -10,14 +12,20 @@ script = b'console.log("hello world");\n' +directories = { + "/": ("main.js", "main-symlink.js", "sub"), + "/sub": ("nested.js",), +} +files = ("/main.js", "/sub/nested.js") + class TestingFs(fuse.Fuse): def getattr(self, path): st = fuse.Stat() - if path == "/": + if path in directories: st.st_mode = stat.S_IFDIR | 0o755 st.st_nlink = 2 - elif path == "/main.js": + elif path in files: st.st_mode = stat.S_IFREG | 0o644 st.st_nlink = 1 st.st_size = len(script) @@ -30,18 +38,18 @@ def getattr(self, path): return st def readdir(self, path, offset): - for r in ".", "..", "main.js", "main-symlink.js": + for r in (".", "..") + directories.get(path, ()): yield fuse.Direntry(r) def open(self, path, flags): - if path != "/main.js" and path != "/main-symlink.js": + if path not in files and path != "/main-symlink.js": return -errno.ENOENT mask = os.O_RDONLY | os.O_WRONLY | os.O_RDWR if (flags & mask) != os.O_RDONLY: return -errno.EACCES def read(self, path, size, offset): - if path != "/main.js": + if path not in files: return -errno.ENOENT if offset < len(script): if offset + size > len(script): diff --git a/test/cli/run/glob-on-fuse.test.ts b/test/cli/run/glob-on-fuse.test.ts index 19584d1a1939..371a22ee38d4 100644 --- a/test/cli/run/glob-on-fuse.test.ts +++ b/test/cli/run/glob-on-fuse.test.ts @@ -6,7 +6,7 @@ */ import { spawn, type ReadableSubprocess } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { isLinux, tmpdirSync } from "harness"; +import { isLinux, tempDir, tmpdirSync } from "harness"; import fs from "node:fs"; import { join } from "node:path"; @@ -91,4 +91,25 @@ describe.skipIf(!isLinux)("glob on a FUSE mount", () => { expect(symlink).toBeDefined(); expect(symlink!.isSymbolicLink()).toBe(true); }); + + // The native recursive copy reads the entry types from readdir. With + // DT_UNKNOWN it has to lstat each entry, or it copies `sub` as a file. + test("fs.cpSync and fs.promises.cp copy a tree from a FUSE mount", async () => { + using dest = tempDir("cp-from-fuse", {}); + const copies = { cpSync: join(String(dest), "sync"), cp: join(String(dest), "async") }; + fs.cpSync(mountpoint, copies.cpSync, { recursive: true }); + await fs.promises.cp(mountpoint, copies.cp, { recursive: true }); + + for (const copy of Object.values(copies)) { + expect({ + main: fs.readFileSync(join(copy, "main.js"), "utf8"), + nested: fs.readFileSync(join(copy, "sub", "nested.js"), "utf8"), + symlink: fs.readlinkSync(join(copy, "main-symlink.js")), + }).toEqual({ + main: 'console.log("hello world");\n', + nested: 'console.log("hello world");\n', + symlink: join(mountpoint, "main.js"), + }); + } + }); }); diff --git a/test/js/node/fs/cp.test.ts b/test/js/node/fs/cp.test.ts index c1960ed51689..dbbd5236fa81 100644 --- a/test/js/node/fs/cp.test.ts +++ b/test/js/node/fs/cp.test.ts @@ -301,6 +301,185 @@ for (const [name, copy] of impls) { }); }); + for (const withLinks of [false, true]) { + test.skipIf(isWindows)( + `recursive - the default copy and the walker give the same tree${withLinks ? " (with symlinks)" : ""}`, + async () => { + // A `filter` always takes the node-ported walker. The default options + // take the native copy where it applies. + await using basename = tempDir("cp", { + "from/a.txt": "a", + "from/empty.txt": "", + "from/bin/run.sh": "#!/bin/sh\n", + "from/nested/deep/er/f.txt": "f", + "from/private/secret.txt": "s", + }); + const from = join(basename, "from"); + fs.mkdirSync(join(from, "empty-dir")); + fs.chmodSync(join(from, "bin", "run.sh"), 0o755); + fs.chmodSync(join(from, "private", "secret.txt"), 0o600); + fs.chmodSync(join(from, "private"), 0o700); + fs.chmodSync(join(from, "nested", "deep"), 0o750); + if (withLinks) { + fs.symlinkSync("a.txt", join(from, "rel-link")); + fs.symlinkSync(join("nested", "deep") + "/", join(from, "dir-link")); + fs.symlinkSync(join(from, "a.txt"), join(from, "abs-link")); + fs.symlinkSync("missing", join(from, "nested", "dangling")); + } + + function snapshot(root: string) { + const tree: Record = { ".": fs.statSync(root).mode & 0o7777 }; + for (const name of (fs.readdirSync(root, { recursive: true }) as string[]).sort()) { + const stat = fs.lstatSync(join(root, name)); + const mode = stat.mode & 0o7777; + if (stat.isSymbolicLink()) tree[name] = { link: fs.readlinkSync(join(root, name)) }; + else if (stat.isDirectory()) tree[name] = { dir: mode }; + else tree[name] = { file: mode, content: fs.readFileSync(join(root, name), "utf8") }; + } + return tree; + } + + await copy(from, join(basename, "default"), { recursive: true }); + await copy(from, join(basename, "walker"), { recursive: true, filter: () => true }); + + const walker = snapshot(join(basename, "walker")); + expect(walker["private"]).toEqual({ dir: 0o700 }); + expect(snapshot(join(basename, "default"))).toEqual(walker); + }, + ); + } + + test.skipIf(isWindows)("recursive - an existing empty destination keeps its mode", async () => { + // node only sets the mode of a directory it creates (verified against + // fs.promises.cp in node v26.3.0). + await using basename = tempDir("cp", { + "from/d/f.txt": "x", + }); + fs.chmodSync(join(basename, "from", "d"), 0o700); + fs.chmodSync(join(basename, "from"), 0o700); + fs.mkdirSync(join(basename, "result")); + fs.chmodSync(join(basename, "result"), 0o755); + + await copy(join(basename, "from"), join(basename, "result"), { recursive: true }); + + expect({ + destMode: fs.statSync(join(basename, "result")).mode & 0o777, + dirMode: fs.statSync(join(basename, "result", "d")).mode & 0o777, + content: fs.readFileSync(join(basename, "result", "d", "f.txt"), "utf8"), + }).toEqual({ + destMode: 0o755, + dirMode: 0o700, + content: "x", + }); + }); + + test.skipIf(isWindows)( + "recursive - a source directory without write permission is copied with its mode", + async () => { + // node sets a directory's mode after it copies the entries. Setting it + // first would make the copy into the directory fail with EACCES. + await using basename = tempDir("cp", { + "from/ro/a.txt": "a", + "from/ro/sub/b.txt": "b", + }); + const readOnlyDirs = ["from", "result"].flatMap(root => [ + join(basename, root, "ro", "sub"), + join(basename, root, "ro"), + ]); + fs.chmodSync(join(basename, "from", "ro", "sub"), 0o555); + fs.chmodSync(join(basename, "from", "ro"), 0o555); + try { + await copy(join(basename, "from"), join(basename, "result"), { recursive: true }); + + expect({ + roMode: fs.statSync(join(basename, "result", "ro")).mode & 0o777, + subMode: fs.statSync(join(basename, "result", "ro", "sub")).mode & 0o777, + a: fs.readFileSync(join(basename, "result", "ro", "a.txt"), "utf8"), + b: fs.readFileSync(join(basename, "result", "ro", "sub", "b.txt"), "utf8"), + }).toEqual({ + roMode: 0o555, + subMode: 0o555, + a: "a", + b: "b", + }); + } finally { + // tempDir cannot remove the entries of a directory it cannot write to. + for (const dir of readOnlyDirs) { + if (fs.existsSync(dir)) fs.chmodSync(dir, 0o755); + } + } + }, + ); + + // root reads any file, so the copy cannot fail this way. macOS does not + // copy file by file: it clones the whole tree with one clonefile(). + test.skipIf(!isLinux || process.getuid?.() === 0)( + "a file that cannot be read fails as a copyfile error", + async () => { + await using basename = tempDir("cp", { + "from/d/secret.txt": "secret", + }); + const secret = join(basename, "from", "d", "secret.txt"); + fs.chmodSync(secret, 0o000); + + const inTree = await copyShouldThrow(join(basename, "from"), join(basename, "result"), { recursive: true }); + const alone = await copyShouldThrow(secret, join(basename, "out.txt")); + + // node copies each file with copyFile (verified against fs.promises.cp + // in node v26.3.0). + const shape = (e: any) => ({ code: e.code, syscall: e.syscall, path: e.path, dest: e.dest }); + expect({ inTree: shape(inTree), alone: shape(alone) }).toEqual({ + inTree: { + code: "EACCES", + syscall: "copyfile", + path: secret, + dest: join(basename, "result", "d", "secret.txt"), + }, + alone: { code: "EACCES", syscall: "copyfile", path: secret, dest: join(basename, "out.txt") }, + }); + }, + ); + + test.skipIf(!isLinux || process.getuid?.() === 0)( + "recursive - a file in a directory without search permission fails at lstat", + async () => { + // readdir only needs read permission, so the directory lists its file. + // node stats the entry before it copies it (verified against + // fs.promises.cp in node v26.3.0). + await using basename = tempDir("cp", { + "from/locked/f.txt": "x", + }); + const locked = join(basename, "from", "locked"); + fs.chmodSync(locked, 0o600); + try { + const e = await copyShouldThrow(join(basename, "from"), join(basename, "result"), { recursive: true }); + expect({ code: e.code, syscall: e.syscall, path: e.path, dest: e.dest }).toEqual({ + code: "EACCES", + syscall: "lstat", + path: join(locked, "f.txt"), + dest: undefined, + }); + } finally { + fs.chmodSync(locked, 0o700); + } + }, + ); + + test.skipIf(isWindows)( + "symlinks - relative target with a trailing slash is resolved like path.resolve", + async () => { + await using basename = tempDir("cp", { + "from/sub/keep.txt": "keep", + }); + fs.symlinkSync("sub/", join(basename, "from", "link")); + + await copy(join(basename, "from"), join(basename, "result"), { recursive: true }); + + // Verified against node v26.3.0: no trailing separator. + expect(fs.readlinkSync(join(basename, "result", "link"))).toBe(join(basename, "from", "sub")); + }, + ); + test.skipIf(isWindows)("recursive - FIFO inside the tree is rejected with ERR_FS_CP_FIFO_PIPE", async () => { await using basename = tempDir("cp", { "from/a.txt": "a", @@ -312,6 +491,18 @@ for (const [name, copy] of impls) { expect(e.code).toBe("ERR_FS_CP_FIFO_PIPE"); }); + test.skipIf(isWindows)("recursive - FIFO in one of many sibling directories is rejected", async () => { + // More directories on one level than the source scan reads at a time. + await using basename = tempDir("cp", { + "from/a.txt": "a", + }); + for (let d = 0; d < 130; d++) fs.mkdirSync(join(basename, "from", `dir-${d}`)); + mkfifo(join(basename, "from", "dir-129", "pipe"), 0o666); + + const e = await copyShouldThrow(join(basename, "from"), join(basename, "result"), { recursive: true }); + expect(e.code).toBe("ERR_FS_CP_FIFO_PIPE"); + }); + test("filter - works", async () => { await using basename = tempDir("cp", { "from/a.txt": "a", @@ -593,6 +784,66 @@ describe.skipIf(isWindows).each(["cp", "cpSync"] as const)( }, ); +// An empty destination has to fail the way node's walker fails it: mkdir("") +// -> ENOENT, with nothing created. (node's cpSync alone reports whatever +// std::filesystem::create_directories("") says in its C++ copy, EINVAL on Linux +// and ESRCH on Windows; given a filter it walks in JS and reports ENOENT/mkdir +// like its async forms do everywhere.) +// On macOS a directory source takes the native recursive copy, which rebuilds +// its destination from a stack buffer; an empty operand used to be returned +// without ever being copied into that buffer, so the tree was copied to +// whatever bytes the buffer happened to hold. The copies run in a child so "" +// resolves against a throwaway cwd, and the listing afterwards proves the cwd +// is untouched. +test("empty destination is rejected with ENOENT and creates nothing", async () => { + using dir = tempDir("cp-empty-dest", { + "d/f": "f", + "d/sub/g": "g", + "file": "file", + }); + const script = ` + const fs = require("fs"); + const shape = e => ({ code: e.code, syscall: e.syscall }); + const forms = { + cpSync: (src, opts) => { try { fs.cpSync(src, "", opts); } catch (e) { return shape(e); } }, + "promises.cp": (src, opts) => fs.promises.cp(src, "", opts).then(() => undefined, shape), + cp: (src, opts) => new Promise(resolve => fs.cp(src, "", opts, e => resolve(e ? shape(e) : undefined))), + }; + (async () => { + const results = {}; + for (const src of ["d", "file"]) { + for (const name in forms) { + results[name + "(" + src + ")"] = (await forms[name](src, { recursive: true })) ?? "resolved"; + } + } + console.log(JSON.stringify({ results, cwd: fs.readdirSync(".", { recursive: true }) })); + })(); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { results, cwd } = JSON.parse(stdout); + // Single-file copies report whichever syscall hit "" on that platform (mkdir + // of its parent on Linux, copyfile on Windows), so only the code is pinned + // for them. + expect({ ...results, cwd: cwd.map((entry: string) => entry.replaceAll("\\", "/")).sort() }).toEqual({ + "cpSync(d)": { code: "ENOENT", syscall: "mkdir" }, + "promises.cp(d)": { code: "ENOENT", syscall: "mkdir" }, + "cp(d)": { code: "ENOENT", syscall: "mkdir" }, + "cpSync(file)": { code: "ENOENT", syscall: expect.any(String) }, + "promises.cp(file)": { code: "ENOENT", syscall: expect.any(String) }, + "cp(file)": { code: "ENOENT", syscall: expect.any(String) }, + cwd: ["d", "d/f", "d/sub", "d/sub/g", "file"], + }); + expect(exitCode).toBe(0); +}); + // fs.promises.cp recursive: when one SingleTask copy fails while siblings are // still in flight on the thread pool, the parent AsyncCpTask must not be // destroyed until every subtask has dropped its reference. Before the fix, @@ -655,6 +906,147 @@ test.skipIf(!isPosix)( }, ); +test("fs.promises.cp with errorOnExist and without force rejects an existing empty destination", async () => { + // Verified against node v26.3.0. Nothing has to be merged, but node checks + // the destination directory itself. + using dir = tempDir("cp-error-on-exist", { + "from/d/f.txt": "x", + }); + fs.mkdirSync(join(String(dir), "result")); + + await expect( + fs.promises.cp(join(String(dir), "from"), join(String(dir), "result"), { + recursive: true, + errorOnExist: true, + force: false, + }), + ).rejects.toMatchObject({ code: "ERR_FS_CP_EEXIST" }); +}); + +// The node-ported walker copies one entry at a time and awaits several thread +// pool round trips for each. The native copy runs the file copies on the thread +// pool in parallel, so a copy it does exactly like node must not go through the +// walker. The walker binds copyFile/copyFileSync when it first loads, so a +// fresh process can count how many files it copied. +describe.concurrent("recursive cp takes the JS walker only when the native copy can differ from node", () => { + // `options` is source text for the child process. + type Case = { + label: string; + options?: string; + withSymlink?: boolean; + destExists?: boolean; + depth?: number; + walker?: boolean; + mayFail?: boolean; + skip: boolean; + }; + const cases: Case[] = [ + // Windows keeps the walker: the native copy there stops at MAX_PATH. + { label: "default options", skip: isWindows }, + { label: "destination is an empty directory", destExists: true, skip: isWindows }, + // `force` and `errorOnExist` only matter for an entry that exists at the + // destination, and the native copy ignores `mode` like a plain copy does. + { label: "force: false", options: "{ force: false }", skip: isWindows }, + { label: "errorOnExist without force", options: "{ errorOnExist: true, force: false }", skip: isWindows }, + { label: "mode: COPYFILE_FICLONE", options: "{ mode: fs.constants.COPYFILE_FICLONE }", skip: isWindows }, + // macOS keeps the walker: clonefile() copies a relative link target as + // written, and node rewrites it against the source tree. + { label: "tree contains a relative symlink", withSymlink: true, skip: !isLinux }, + // The copy has to fail where a clone is not possible. Only copyFile does + // that, so only the route is checked. + { + label: "mode: COPYFILE_FICLONE_FORCE", + options: "{ mode: fs.constants.COPYFILE_FICLONE_FORCE }", + walker: true, + mayFail: true, + skip: false, + }, + { label: "filter", options: "{ filter: () => true }", walker: true, skip: false }, + // The native copy recurses once per directory level on a thread stack. + { label: "tree deeper than 64 levels", depth: 70, walker: true, skip: false }, + ]; + const forms = ["cpSync", "cp", "promises.cp"]; + for (const { + label, + options = "{}", + withSymlink = false, + destExists = false, + depth = 0, + walker = false, + mayFail = false, + skip, + } of cases) { + test.skipIf(skip)(label, async () => { + const files: Record = {}; + for (let d = 0; d < 4; d++) { + for (let f = 0; f < 4; f++) files[`from/dir-${d}/file-${f}.txt`] = `${d}-${f}`; + } + using dir = tempDir("cp-native-path", files); + if (depth) { + const levels = Array.from({ length: depth }, () => "n"); + fs.mkdirSync(join(String(dir), "from", ...levels), { recursive: true }); + fs.writeFileSync(join(String(dir), "from", ...levels, "deep.txt"), "deep"); + } + if (withSymlink) fs.symlinkSync(join("dir-0", "file-0.txt"), join(String(dir), "from", "link")); + if (destExists) { + for (const form of forms) fs.mkdirSync(join(String(dir), "to-" + form)); + } + + const script = ` + import fs from "node:fs"; + let walkerCopies = 0; + const { copyFileSync } = fs; + const { copyFile } = fs.promises; + fs.copyFileSync = function () { walkerCopies++; return copyFileSync.apply(this, arguments); }; + fs.promises.copyFile = function () { walkerCopies++; return copyFile.apply(this, arguments); }; + const options = { recursive: true, ...${options} }; + const copies = { + "cpSync": dest => fs.cpSync("from", dest, options), + "cp": dest => new Promise((resolve, reject) => fs.cp("from", dest, options, err => (err ? reject(err) : resolve()))), + "promises.cp": dest => fs.promises.cp("from", dest, options), + }; + const result = {}; + for (const form in copies) { + walkerCopies = 0; + let error = null; + try { await copies[form]("to-" + form); } catch (e) { error = e.code; } + const copied = fs.existsSync("to-" + form) + ? fs.readdirSync("to-" + form, { recursive: true }).filter(name => name.endsWith(".txt")).length + : 0; + result[form] = { tookWalker: walkerCopies > 0, copied, error }; + } + console.log(JSON.stringify(result)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const result = JSON.parse(stdout); + if (mayFail) { + expect(forms.map(form => [form, result[form].tookWalker])).toEqual(forms.map(form => [form, walker])); + } else { + const copied = depth ? 17 : 16; + expect(result).toEqual( + Object.fromEntries(forms.map(form => [form, { tookWalker: walker, copied, error: null }])), + ); + } + expect(exitCode).toBe(0); + if (withSymlink) { + // The child resolves the link against its working directory. + const cwd = fs.realpathSync(String(dir)); + for (const form of forms) { + expect(fs.readlinkSync(join(cwd, "to-" + form, "link"))).toBe(join(cwd, "from", "dir-0", "file-0.txt")); + } + } + }); + } +}); + test.skipIf(!isLinux)("fs.cp and fs.copyFile create the destination with the source file's mode", async () => { using dir = tempDir("cp-dest-mode", {}); const destNames = ["dest-copyFile.bin", "dest-cp.bin"];