From ab708e0099d272ec6cca280a7f53fee710b5fc46 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Tue, 9 Sep 2025 16:29:28 -0700 Subject: [PATCH] Implement test-fs-cp.mjs --- src/bun.js/bindings/ErrorCode.ts | 2 + src/bun.js/modules/NodeModuleModule.cpp | 36 +- src/bun.js/node/node_fs.zig | 5 +- src/js/internal/fs/cp-sync.ts | 111 ++- src/js/internal/fs/cp.ts | 109 ++- src/js/node/fs.promises.ts | 2 +- src/js/node/fs.ts | 48 +- test/js/node/test/parallel/test-fs-cp.mjs | 1076 +++++++++++++++++++++ 8 files changed, 1339 insertions(+), 50 deletions(-) create mode 100644 test/js/node/test/parallel/test-fs-cp.mjs diff --git a/src/bun.js/bindings/ErrorCode.ts b/src/bun.js/bindings/ErrorCode.ts index 37a9ce660b..72fdecd712 100644 --- a/src/bun.js/bindings/ErrorCode.ts +++ b/src/bun.js/bindings/ErrorCode.ts @@ -75,10 +75,12 @@ const errors: ErrorCodeMapping = [ ["ERR_EXECUTION_ENVIRONMENT_NOT_AVAILABLE", Error], ["ERR_FORMDATA_PARSE_ERROR", TypeError], ["ERR_FS_CP_DIR_TO_NON_DIR", Error], + ["ERR_FS_CP_EEXIST", Error], ["ERR_FS_CP_EINVAL", Error], ["ERR_FS_CP_FIFO_PIPE", Error], ["ERR_FS_CP_NON_DIR_TO_DIR", Error], ["ERR_FS_CP_SOCKET", Error], + ["ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY", Error], ["ERR_FS_CP_UNKNOWN", Error], ["ERR_FS_EISDIR", Error], ["ERR_HTTP_BODY_NOT_ALLOWED", Error], diff --git a/src/bun.js/modules/NodeModuleModule.cpp b/src/bun.js/modules/NodeModuleModule.cpp index 67c8de6780..9664d6fed5 100644 --- a/src/bun.js/modules/NodeModuleModule.cpp +++ b/src/bun.js/modules/NodeModuleModule.cpp @@ -557,14 +557,21 @@ static JSValue getModuleExtensionsObject(VM& vm, JSObject* moduleObject) static JSValue getModuleDebugObject(VM& vm, JSObject* moduleObject) { - return JSC::constructEmptyObject(moduleObject->globalObject()); + auto* globalObject = moduleObject->globalObject(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue result = JSC::constructEmptyObject(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return result; } static JSValue getPathCacheObject(VM& vm, JSObject* moduleObject) { auto* globalObject = defaultGlobalObject(moduleObject->globalObject()); - return JSC::constructEmptyObject( + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue result = JSC::constructEmptyObject( vm, globalObject->nullPrototypeObjectStructure()); + RETURN_IF_EXCEPTION(scope, {}); + return result; } static JSValue getSourceMapFunction(VM& vm, JSObject* moduleObject) @@ -578,6 +585,9 @@ static JSValue getSourceMapFunction(VM& vm, JSObject* moduleObject) static JSValue getBuiltinModulesObject(VM& vm, JSObject* moduleObject) { + auto* globalObject = defaultGlobalObject(moduleObject->globalObject()); + auto scope = DECLARE_THROW_SCOPE(vm); + MarkedArgumentBuffer args; args.ensureCapacity(countof(builtinModuleNames)); @@ -585,15 +595,20 @@ static JSValue getBuiltinModulesObject(VM& vm, JSObject* moduleObject) args.append(JSC::jsOwnedString(vm, String(builtinModuleNames[i]))); } - auto* globalObject = defaultGlobalObject(moduleObject->globalObject()); - return JSC::constructArray(globalObject, static_cast(nullptr), JSC::ArgList(args)); + JSValue result = JSC::constructArray(globalObject, static_cast(nullptr), JSC::ArgList(args)); + RETURN_IF_EXCEPTION(scope, {}); + return result; } static JSValue getConstantsObject(VM& vm, JSObject* moduleObject) { auto* globalObject = defaultGlobalObject(moduleObject->globalObject()); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* compileCacheStatus = JSC::constructEmptyObject( vm, globalObject->nullPrototypeObjectStructure()); + RETURN_IF_EXCEPTION(scope, {}); + compileCacheStatus->putDirect(vm, JSC::Identifier::fromString(vm, "FAILED"_s), JSC::jsNumber(0)); compileCacheStatus->putDirect( @@ -606,6 +621,8 @@ static JSValue getConstantsObject(VM& vm, JSObject* moduleObject) auto* constantsObject = JSC::constructEmptyObject( vm, globalObject->nullPrototypeObjectStructure()); + RETURN_IF_EXCEPTION(scope, {}); + constantsObject->putDirect( vm, JSC::Identifier::fromString(vm, "compileCacheStatus"_s), compileCacheStatus); @@ -614,9 +631,13 @@ static JSValue getConstantsObject(VM& vm, JSObject* moduleObject) static JSValue getGlobalPathsObject(VM& vm, JSObject* moduleObject) { - return JSC::constructEmptyArray( - moduleObject->globalObject(), + auto* globalObject = moduleObject->globalObject(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue result = JSC::constructEmptyArray( + globalObject, static_cast(nullptr), 0); + RETURN_IF_EXCEPTION(scope, {}); + return result; } JSC_DEFINE_HOST_FUNCTION(jsFunctionSetCJSWrapperItem, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) @@ -690,7 +711,10 @@ JSC_DEFINE_CUSTOM_SETTER(setNodeModuleWrapper, static JSValue getModulePrototypeObject(VM& vm, JSObject* moduleObject) { auto* globalObject = defaultGlobalObject(moduleObject->globalObject()); + auto scope = DECLARE_THROW_SCOPE(vm); + auto prototype = constructEmptyObject(globalObject, globalObject->objectPrototype(), 2); + RETURN_IF_EXCEPTION(scope, {}); prototype->putDirectCustomAccessor( vm, WebCore::clientData(vm)->builtinNames().requirePublicName(), diff --git a/src/bun.js/node/node_fs.zig b/src/bun.js/node/node_fs.zig index d06d3d108b..9656a4a4ce 100644 --- a/src/bun.js/node/node_fs.zig +++ b/src/bun.js/node/node_fs.zig @@ -3076,7 +3076,10 @@ pub const Arguments = struct { if (arguments.next()) |arg| { arguments.eat(); - force = arg.toBoolean(); + // Keep default true if undefined is passed + if (!arg.isUndefinedOrNull()) { + force = arg.toBoolean(); + } } if (arguments.next()) |arg| { diff --git a/src/js/internal/fs/cp-sync.ts b/src/js/internal/fs/cp-sync.ts index bc716a53dd..674fcd3acd 100644 --- a/src/js/internal/fs/cp-sync.ts +++ b/src/js/internal/fs/cp-sync.ts @@ -10,8 +10,15 @@ function areIdentical(srcStat, destStat) { return destStat.ino && destStat.dev && destStat.ino === srcStat.ino && destStat.dev === srcStat.dev; } -const normalizePathToArray = path => - ArrayPrototypeFilter.$call(StringPrototypeSplit.$call(resolve(path), sep), Boolean); +const normalizePathToArray = path => { + // Handle URL objects and strings + const pathStr = typeof path === 'object' && path !== null && path.pathname + ? path.pathname + : typeof path === 'object' && path !== null && path.href + ? new URL(path.href).pathname + : path; + return ArrayPrototypeFilter.$call(StringPrototypeSplit.$call(resolve(pathStr), sep), Boolean); +}; function isSrcSubdir(src, dest) { const srcArr = normalizePathToArray(src); @@ -41,6 +48,7 @@ const { // opendirSync, readdirSync, readlinkSync, + realpathSync, statSync, symlinkSync, unlinkSync, @@ -48,24 +56,89 @@ const { } = require("node:fs"); const { dirname, isAbsolute, join, parse, resolve, sep } = require("node:path"); +function validateDestinationPath(src, dest) { + // Convert URLs to paths if necessary + // Handle both URL objects and strings + const srcPath = typeof src === 'object' && src !== null ? (src.pathname || src.toString()) : src; + const destPath = typeof dest === 'object' && dest !== null ? (dest.pathname || dest.toString()) : dest; + + // Skip validation if paths are not strings (shouldn't happen, but be safe) + if (typeof srcPath !== 'string' || typeof destPath !== 'string') { + return; + } + + // Only validate if source exists and is a directory + // (sockets, pipes, etc. are handled elsewhere) + try { + const srcStat = statSync(srcPath); + if (!srcStat.isDirectory()) { + return; // Skip validation for non-directories + } + } catch { + return; // Source doesn't exist, skip validation + } + + // Check each parent directory in the destination path to see if any + // are symlinks that point back to the source or its parents + let currentPath = dirname(destPath); // Start with parent of dest + const resolvedSrc = realpathSync(srcPath); + + while (currentPath && currentPath !== parse(currentPath).root) { + try { + // Check if this path component exists and might be a symlink + if (existsSync(currentPath)) { + const resolvedPath = realpathSync(currentPath); + // Get the part of dest that comes after this path + const remainingPath = destPath.slice(currentPath.length); + const fullResolvedDest = resolvedPath + remainingPath; + + // Check if the resolved destination would be inside the source + if (fullResolvedDest.startsWith(resolvedSrc + sep) || fullResolvedDest === resolvedSrc) { + throw $ERR_FS_CP_EINVAL(`cannot copy ${srcPath} to a subdirectory of self ${destPath}`); + } + } + } catch (err) { + // Re-throw ERR_FS_CP_EINVAL errors + if (err.code === 'ERR_FS_CP_EINVAL') { + throw err; + } + // Ignore other errors (like ENOENT) and continue checking parent directories + } + + currentPath = dirname(currentPath); + } +} + function cpSyncFn(src, dest, opts) { // Warn about using preserveTimestamps on 32-bit node // if (opts.preserveTimestamps && process.arch === "ia32") { // const warning = "Using the preserveTimestamps option in 32-bit " + "node is not recommended"; // process.emitWarning(warning, "TimestampPrecisionWarning"); // } - const { srcStat, destStat, skipped } = checkPathsSync(src, dest, opts); + + // Convert URL objects to paths if necessary + // Use decodeURIComponent to handle URL-encoded characters like %25 -> % + const srcPath = typeof src === 'object' && src !== null + ? decodeURIComponent(src.pathname || (src.href ? new URL(src.href).pathname : src.toString())) + : src; + const destPath = typeof dest === 'object' && dest !== null + ? decodeURIComponent(dest.pathname || (dest.href ? new URL(dest.href).pathname : dest.toString())) + : dest; + + // Check if dest path contains symlinks that would create circular reference + validateDestinationPath(srcPath, destPath); + + const { srcStat, destStat, skipped } = checkPathsSync(srcPath, destPath, opts); if (skipped) return; - checkParentPathsSync(src, srcStat, dest); - return checkParentDir(destStat, src, dest, opts); + checkParentPathsSync(srcPath, srcStat, destPath); + return checkParentDir(destStat, srcPath, destPath, opts); } function checkPathsSync(src, dest, opts) { if (opts.filter) { const shouldCopy = opts.filter(src, dest); if ($isPromise(shouldCopy)) { - // throw new ERR_INVALID_RETURN_VALUE("boolean", "filter", shouldCopy); - throw new Error("Expected a boolean from the filter function, but got a promise. Use `fs.promises.cp` instead."); + throw $ERR_INVALID_RETURN_VALUE("boolean", "filter", shouldCopy); } if (!shouldCopy) return { __proto__: null, skipped: true }; } @@ -80,7 +153,7 @@ function checkPathsSync(src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error("src and dest cannot be the same"); + throw $ERR_FS_CP_EINVAL("src and dest cannot be the same"); } if (srcStat.isDirectory() && !destStat.isDirectory()) { // throw new ERR_FS_CP_DIR_TO_NON_DIR({ @@ -90,7 +163,7 @@ function checkPathsSync(src, dest, opts) { // errno: EISDIR, // code: "EISDIR", // }); - throw new Error(`cannot overwrite directory ${src} with non-directory ${dest}`); + throw $ERR_FS_CP_DIR_TO_NON_DIR(`cannot overwrite directory ${src} with non-directory ${dest}`); } if (!srcStat.isDirectory() && destStat.isDirectory()) { // throw new ERR_FS_CP_NON_DIR_TO_DIR({ @@ -100,7 +173,7 @@ function checkPathsSync(src, dest, opts) { // errno: ENOTDIR, // code: "ENOTDIR", // }); - throw new Error(`cannot overwrite non-directory ${src} with directory ${dest}`); + throw $ERR_FS_CP_NON_DIR_TO_DIR(`cannot overwrite non-directory ${src} with directory ${dest}`); } } @@ -112,7 +185,7 @@ function checkPathsSync(src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy ${src} to a subdirectory of self ${dest}`); + throw $ERR_FS_CP_EINVAL(`cannot copy ${src} to a subdirectory of self ${dest}`); } return { __proto__: null, srcStat, destStat, skipped: false }; } @@ -151,7 +224,7 @@ function checkParentPathsSync(src, srcStat, dest) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy ${src} to a subdirectory of self ${dest}`); + throw $ERR_FS_CP_EINVAL(`cannot copy ${src} to a subdirectory of self ${dest}`); } return checkParentPathsSync(src, srcStat, destParent); } @@ -176,7 +249,7 @@ function getStats(destStat, src, dest, opts) { // errno: EINVAL, // code: "EISDIR", // }); - throw new Error(`${src} is a directory (not copied)`); + throw $ERR_FS_EISDIR(`${src} is a directory (not copied)`); } else if (srcStat.isFile() || srcStat.isCharacterDevice() || srcStat.isBlockDevice()) { return onFile(srcStat, destStat, src, dest, opts); } else if (srcStat.isSymbolicLink()) { @@ -189,7 +262,7 @@ function getStats(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy a socket file: ${dest}`); + throw $ERR_FS_CP_SOCKET(`cannot copy a socket file: ${dest}`); } else if (srcStat.isFIFO()) { // throw new ERR_FS_CP_FIFO_PIPE({ // message: `cannot copy a FIFO pipe: ${dest}`, @@ -198,7 +271,7 @@ function getStats(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy a FIFO pipe: ${dest}`); + throw $ERR_FS_CP_FIFO_PIPE(`cannot copy a FIFO pipe: ${dest}`); } // throw new ERR_FS_CP_UNKNOWN({ // message: `cannot copy an unknown file type: ${dest}`, @@ -207,7 +280,7 @@ function getStats(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy an unknown file type: ${dest}`); + throw $ERR_FS_CP_UNKNOWN(`cannot copy an unknown file type: ${dest}`); } function onFile(srcStat, destStat, src, dest, opts) { @@ -227,7 +300,7 @@ function mayCopyFile(srcStat, src, dest, opts) { // errno: EEXIST, // code: "EEXIST", // }); - throw new Error(`${dest} already exists`); + throw $ERR_FS_CP_EEXIST(`${dest} already exists`); } } @@ -330,7 +403,7 @@ function onLink(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy ${resolvedSrc} to a subdirectory of self ${resolvedDest}`); + throw $ERR_FS_CP_EINVAL(`cannot copy ${resolvedSrc} to a subdirectory of self ${resolvedDest}`); } // Prevent copy if src is a subdir of dest since unlinking // dest in this case would result in removing src contents @@ -343,7 +416,7 @@ function onLink(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot overwrite ${resolvedDest} with ${resolvedSrc}`); + throw $ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY(`cannot overwrite ${resolvedDest} with ${resolvedSrc}`); } return copyLink(resolvedSrc, dest); } diff --git a/src/js/internal/fs/cp.ts b/src/js/internal/fs/cp.ts index a58d74cd3b..15c1f4ce8b 100644 --- a/src/js/internal/fs/cp.ts +++ b/src/js/internal/fs/cp.ts @@ -14,7 +14,7 @@ // }, // } = require("internal/errors"); // const { EEXIST, EISDIR, EINVAL, ENOTDIR } = $processBindingConstants.os.errno; -const { chmod, copyFile, lstat, mkdir, opendir, readlink, stat, symlink, unlink, utimes } = require("node:fs/promises"); +const { access, chmod, copyFile, lstat, mkdir, opendir, readlink, realpath, stat, symlink, unlink, utimes } = require("node:fs/promises"); const { dirname, isAbsolute, join, parse, resolve, sep } = require("node:path"); const PromisePrototypeThen = $Promise.prototype.$then; @@ -23,12 +23,78 @@ const ArrayPrototypeFilter = Array.prototype.filter; const StringPrototypeSplit = String.prototype.split; const ArrayPrototypeEvery = Array.prototype.every; +async function validateDestinationPath(src, dest) { + // Convert URLs to paths if necessary + // Handle both URL objects and strings + const srcPath = typeof src === 'object' && src !== null ? (src.pathname || src.toString()) : src; + const destPath = typeof dest === 'object' && dest !== null ? (dest.pathname || dest.toString()) : dest; + + // Skip validation if paths are not strings (shouldn't happen, but be safe) + if (typeof srcPath !== 'string' || typeof destPath !== 'string') { + return; + } + + // Only validate if source exists and is a directory + // (sockets, pipes, etc. are handled elsewhere) + try { + const srcStat = await stat(srcPath); + if (!srcStat.isDirectory()) { + return; // Skip validation for non-directories + } + } catch { + return; // Source doesn't exist, skip validation + } + + // Check each parent directory in the destination path to see if any + // are symlinks that point back to the source or its parents + let currentPath = dirname(destPath); // Start with parent of dest + const resolvedSrc = await realpath(srcPath); + + while (currentPath && currentPath !== parse(currentPath).root) { + try { + // Check if this path component exists and might be a symlink + const exists = await access(currentPath).then(() => true).catch(() => false); + if (exists) { + const resolvedPath = await realpath(currentPath); + // Get the part of dest that comes after this path + const remainingPath = destPath.slice(currentPath.length); + const fullResolvedDest = resolvedPath + remainingPath; + + // Check if the resolved destination would be inside the source + if (fullResolvedDest.startsWith(resolvedSrc + sep) || fullResolvedDest === resolvedSrc) { + throw $ERR_FS_CP_EINVAL(`cannot copy ${srcPath} to a subdirectory of self ${destPath}`); + } + } + } catch (err) { + // Re-throw ERR_FS_CP_EINVAL errors + if (err.code === 'ERR_FS_CP_EINVAL') { + throw err; + } + // Ignore other errors (like ENOENT) and continue checking parent directories + } + + currentPath = dirname(currentPath); + } +} + async function cpFn(src, dest, opts) { - const stats = await checkPaths(src, dest, opts); + // Convert URL objects to paths if necessary + // Use decodeURIComponent to handle URL-encoded characters like %25 -> % + const srcPath = typeof src === 'object' && src !== null + ? decodeURIComponent(src.pathname || (src.href ? new URL(src.href).pathname : src.toString())) + : src; + const destPath = typeof dest === 'object' && dest !== null + ? decodeURIComponent(dest.pathname || (dest.href ? new URL(dest.href).pathname : dest.toString())) + : dest; + + // Check if dest path contains symlinks that would create circular reference + await validateDestinationPath(srcPath, destPath); + + const stats = await checkPaths(srcPath, destPath, opts); const { srcStat, destStat, skipped } = stats; if (skipped) return; - await checkParentPaths(src, srcStat, dest); - return checkParentDir(destStat, src, dest, opts); + await checkParentPaths(srcPath, srcStat, destPath); + return checkParentDir(destStat, srcPath, destPath, opts); } async function checkPaths(src, dest, opts) { @@ -38,7 +104,7 @@ async function checkPaths(src, dest, opts) { const { 0: srcStat, 1: destStat } = await getStats(src, dest, opts); if (destStat) { if (areIdentical(srcStat, destStat)) { - throw new Error("Source and destination must not be the same."); + throw $ERR_FS_CP_EINVAL("Source and destination must not be the same."); } if (srcStat.isDirectory() && !destStat.isDirectory()) { // throw new ERR_FS_CP_DIR_TO_NON_DIR({ @@ -48,7 +114,7 @@ async function checkPaths(src, dest, opts) { // errno: EISDIR, // code: "EISDIR", // }); - throw new Error(`cannot overwrite directory ${src} with non-directory ${dest}`); + throw $ERR_FS_CP_DIR_TO_NON_DIR(`cannot overwrite directory ${src} with non-directory ${dest}`); } if (!srcStat.isDirectory() && destStat.isDirectory()) { // throw new ERR_FS_CP_NON_DIR_TO_DIR({ @@ -58,7 +124,7 @@ async function checkPaths(src, dest, opts) { // errno: ENOTDIR, // code: "ENOTDIR", // }); - throw new Error(`cannot overwrite non-directory ${src} with directory ${dest}`); + throw $ERR_FS_CP_NON_DIR_TO_DIR(`cannot overwrite non-directory ${src} with directory ${dest}`); } } @@ -70,7 +136,7 @@ async function checkPaths(src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy ${src} to a subdirectory of self ${dest}`); + throw $ERR_FS_CP_EINVAL(`cannot copy ${src} to a subdirectory of self ${dest}`); } return { __proto__: null, srcStat, destStat, skipped: false }; } @@ -131,13 +197,20 @@ async function checkParentPaths(src, srcStat, dest) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy ${src} to a subdirectory of self ${dest}`); + throw $ERR_FS_CP_EINVAL(`cannot copy ${src} to a subdirectory of self ${dest}`); } return checkParentPaths(src, srcStat, destParent); } -const normalizePathToArray = path => - ArrayPrototypeFilter.$call(StringPrototypeSplit.$call(resolve(path), sep), Boolean); +const normalizePathToArray = path => { + // Handle URL objects and strings + const pathStr = typeof path === 'object' && path !== null && path.pathname + ? path.pathname + : typeof path === 'object' && path !== null && path.href + ? new URL(path.href).pathname + : path; + return ArrayPrototypeFilter.$call(StringPrototypeSplit.$call(resolve(pathStr), sep), Boolean); +}; // Return true if dest is a subdir of src, otherwise false. // It only checks the path strings. @@ -160,7 +233,7 @@ async function getStatsForCopy(destStat, src, dest, opts) { // errno: EISDIR, // code: "EISDIR", // }); - throw new Error(`${src} is a directory (not copied)`); + throw $ERR_FS_EISDIR(`${src} is a directory (not copied)`); } else if (srcStat.isFile() || srcStat.isCharacterDevice() || srcStat.isBlockDevice()) { return onFile(srcStat, destStat, src, dest, opts); } else if (srcStat.isSymbolicLink()) { @@ -173,7 +246,7 @@ async function getStatsForCopy(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy a socket file: ${dest}`); + throw $ERR_FS_CP_SOCKET(`cannot copy a socket file: ${dest}`); } else if (srcStat.isFIFO()) { // throw new ERR_FS_CP_FIFO_PIPE({ // message: `cannot copy a FIFO pipe: ${dest}`, @@ -182,7 +255,7 @@ async function getStatsForCopy(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy a FIFO pipe: ${dest}`); + throw $ERR_FS_CP_FIFO_PIPE(`cannot copy a FIFO pipe: ${dest}`); } // throw new ERR_FS_CP_UNKNOWN({ // message: `cannot copy an unknown file type: ${dest}`, @@ -191,7 +264,7 @@ async function getStatsForCopy(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy an unknown file type: ${dest}`); + throw $ERR_FS_CP_UNKNOWN(`cannot copy an unknown file type: ${dest}`); } function onFile(srcStat, destStat, src, dest, opts) { @@ -211,7 +284,7 @@ async function mayCopyFile(srcStat, src, dest, opts) { // errno: EEXIST, // code: "EEXIST", // }); - throw new Error(`${dest} already exists`); + throw $ERR_FS_CP_EEXIST(`${dest} already exists`); } } @@ -312,7 +385,7 @@ async function onLink(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot copy ${resolvedSrc} to a subdirectory of self ${resolvedDest}`); + throw $ERR_FS_CP_EINVAL(`cannot copy ${resolvedSrc} to a subdirectory of self ${resolvedDest}`); } // Do not copy if src is a subdir of dest since unlinking // dest in this case would result in removing src contents @@ -326,7 +399,7 @@ async function onLink(destStat, src, dest, opts) { // errno: EINVAL, // code: "EINVAL", // }); - throw new Error(`cannot overwrite ${resolvedDest} with ${resolvedSrc}`); + throw $ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY(`cannot overwrite ${resolvedDest} with ${resolvedSrc}`); } return copyLink(resolvedSrc, dest); } diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index 13d29f8b14..710017d469 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -103,7 +103,7 @@ function watch( function cp(src, dest, options) { if (!options) return fs.cp(src, dest); if (typeof options !== "object") { - throw new TypeError("options must be an object"); + throw $ERR_INVALID_ARG_TYPE("options", "object", options); } if (options.dereference || options.filter || options.preserveTimestamps || options.verbatimSymlinks) { return require("internal/fs/cp")(src, dest, options); diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index 1c56c4f754..1b2b250366 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -997,12 +997,50 @@ 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) { - if (!options) return fs.cpSync(src, dest); - if (typeof options !== "object") { - throw new TypeError("options must be an object"); + if (!options) { + // Check if src and dest are the same for no-options case + if (src === dest) { + throw $ERR_FS_CP_EINVAL("Cannot copy", src, "to itself"); + } + // Use JS implementation to ensure proper symlink validation + // The native implementation doesn't validate symlinks in dest path + return require("internal/fs/cp-sync")(src, dest, { force: true }); } - if (options.dereference || options.filter || options.preserveTimestamps || options.verbatimSymlinks) { - return require("internal/fs/cp-sync")(src, dest, options); + if (typeof options !== "object") { + throw $ERR_INVALID_ARG_TYPE("options", "object", options); + } + // Validate verbatimSymlinks is a boolean if provided + if ("verbatimSymlinks" in options && typeof options.verbatimSymlinks !== "boolean") { + throw $ERR_INVALID_ARG_TYPE("options.verbatimSymlinks", "boolean", options.verbatimSymlinks); + } + // Validate mode is a valid integer if provided + if ("mode" in options) { + const mode = options.mode; + if (!Number.isInteger(mode) || mode < 0) { + throw $ERR_OUT_OF_RANGE("options.mode", ">= 0", mode); + } + } + // Check for incompatible options + if (options.dereference && options.verbatimSymlinks) { + throw $ERR_INCOMPATIBLE_OPTION_PAIR("options.dereference", "options.verbatimSymlinks"); + } + // Check if src and dest are the same after validations + if (src === dest) { + throw $ERR_FS_CP_EINVAL("Cannot copy", src, "to itself"); + } + // Use JS fallback when special options are used + // IMPORTANT: The native implementation doesn't correctly handle symlink resolution + // (it copies symlinks as-is instead of resolving relative paths to absolute). + // So we always use the JS fallback for now to ensure correct behavior. + // Only use native implementation for simple cases without symlinks. + // TODO: Fix the native implementation to handle symlink resolution correctly + if (options.dereference || options.filter || options.preserveTimestamps || "verbatimSymlinks" in options || true) { + // Ensure force defaults to true if not specified + const optsWithDefaults = { + ...options, + force: options.force ?? true + }; + return require("internal/fs/cp-sync")(src, dest, optsWithDefaults); } return fs.cpSync(src, dest, options.recursive, options.errorOnExist, options.force ?? true, options.mode); } diff --git a/test/js/node/test/parallel/test-fs-cp.mjs b/test/js/node/test/parallel/test-fs-cp.mjs new file mode 100644 index 0000000000..d08bbae573 --- /dev/null +++ b/test/js/node/test/parallel/test-fs-cp.mjs @@ -0,0 +1,1076 @@ +import { mustCall, mustNotMutateObjectDeep } from '../common/index.mjs'; +// TODO: isInsideDirWithUnusualChars is not yet implemented in Bun's test infrastructure +const isInsideDirWithUnusualChars = false; + +import assert from 'assert'; +import fs from 'fs'; +const { + cp, + cpSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + symlinkSync, + statSync, + writeFileSync, +} = fs; +import net from 'net'; +import { join } from 'path'; +import { pathToFileURL } from 'url'; +import { setTimeout } from 'timers/promises'; + +const isWindows = process.platform === 'win32'; +import tmpdir from '../common/tmpdir.js'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const testDir = dirname(__dirname); + +tmpdir.refresh(); + +let dirc = 0; +function nextdir(dirname) { + return tmpdir.resolve(dirname || `copy_%${++dirc}`); +} + +// Synchronous implementation of copy. + +// It copies a nested folder containing UTF characters. +{ + const src = join(testDir, 'fixtures/copy/utf/新建文件夹'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + assertDirEquivalent(src, dest); +} + +// It copies a nested folder structure with files and folders. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + assertDirEquivalent(src, dest); +} + +// It copies a nested folder structure with mode flags. +// This test is based on fs.promises.copyFile() with `COPYFILE_FICLONE_FORCE`. +(() => { + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + try { + cpSync(src, dest, mustNotMutateObjectDeep({ + recursive: true, + mode: fs.constants.COPYFILE_FICLONE_FORCE, + })); + } catch (err) { + // If the platform does not support `COPYFILE_FICLONE_FORCE` operation, + // it should enter this path. + assert.strictEqual(err.syscall, 'copyfile'); + assert(err.code === 'ENOTSUP' || err.code === 'ENOTTY' || + err.code === 'ENOSYS' || err.code === 'EXDEV'); + return; + } + + // If the platform support `COPYFILE_FICLONE_FORCE` operation, + // it should reach to here. + assertDirEquivalent(src, dest); +})(); + +// It does not throw errors when directory is copied over and force is false. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'README.md'), 'hello world', 'utf8'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + const initialStat = lstatSync(join(dest, 'README.md')); + cpSync(src, dest, mustNotMutateObjectDeep({ force: false, recursive: true })); + // File should not have been copied over, so access times will be identical: + assertDirEquivalent(src, dest); + const finalStat = lstatSync(join(dest, 'README.md')); + assert.strictEqual(finalStat.ctime.getTime(), initialStat.ctime.getTime()); +} + +// It overwrites existing files if force is true. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(dest, 'README.md'), '# Goodbye', 'utf8'); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + assertDirEquivalent(src, dest); + const content = readFileSync(join(dest, 'README.md'), 'utf8'); + assert.strictEqual(content.trim(), '# Hello'); +} + +// It does not fail if the same directory is copied to dest twice, +// when dereference is true, and force is false (fails silently). +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + const destFile = join(dest, 'a/b/README2.md'); + cpSync(src, dest, mustNotMutateObjectDeep({ dereference: true, recursive: true })); + cpSync(src, dest, mustNotMutateObjectDeep({ dereference: true, recursive: true })); + const stat = lstatSync(destFile); + assert(stat.isFile()); +} + + +// It copies file itself, rather than symlink, when dereference is true. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync(join(src, 'foo.js'), join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + const destFile = join(dest, 'foo.js'); + + cpSync(join(src, 'bar.js'), destFile, mustNotMutateObjectDeep({ dereference: true, recursive: true })); + const stat = lstatSync(destFile); + assert(stat.isFile()); +} + + +// It overrides target directory with what symlink points to, when dereference is true. +{ + const src = nextdir(); + const symlink = nextdir(); + const dest = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync(src, symlink); + + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + + cpSync(symlink, dest, mustNotMutateObjectDeep({ dereference: true, recursive: true })); + const destStat = lstatSync(dest); + assert(!destStat.isSymbolicLink()); + assertDirEquivalent(src, dest); +} + +// It throws error when verbatimSymlinks is not a boolean. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + [1, [], {}, null, 1n, undefined, null, Symbol(), '', () => {}] + .forEach((verbatimSymlinks) => { + assert.throws( + () => cpSync(src, src, { verbatimSymlinks }), + { code: 'ERR_INVALID_ARG_TYPE' } + ); + }); +} + +// It rejects if options.mode is invalid. +{ + assert.throws( + () => cpSync('a', 'b', { mode: -1 }), + { code: 'ERR_OUT_OF_RANGE' } + ); +} + + +// It throws an error when both dereference and verbatimSymlinks are enabled. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + assert.throws( + () => cpSync(src, src, mustNotMutateObjectDeep({ dereference: true, verbatimSymlinks: true })), + { code: 'ERR_INCOMPATIBLE_OPTION_PAIR' } + ); +} + + +// It resolves relative symlinks to their absolute path by default. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync('foo.js', join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + const link = readlinkSync(join(dest, 'bar.js')); + assert.strictEqual(link, join(src, 'foo.js')); +} + + +// It resolves relative symlinks when verbatimSymlinks is false. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync('foo.js', join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true, verbatimSymlinks: false })); + const link = readlinkSync(join(dest, 'bar.js')); + assert.strictEqual(link, join(src, 'foo.js')); +} + + +// It does not resolve relative symlinks when verbatimSymlinks is true. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync('foo.js', join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true, verbatimSymlinks: true })); + const link = readlinkSync(join(dest, 'bar.js')); + assert.strictEqual(link, 'foo.js'); +} + + +// It throws error when src and dest are identical. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + assert.throws( + () => cpSync(src, src), + { code: 'ERR_FS_CP_EINVAL' } + ); +} + +// It throws error if symlink in src points to location in dest. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + const dest = nextdir(); + mkdirSync(dest); + symlinkSync(dest, join(src, 'link')); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + assert.throws( + () => cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })), + { + code: 'ERR_FS_CP_EINVAL' + } + ); +} + +// It throws error if symlink in dest points to location in src. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(src, join(dest, 'a', 'c')); + assert.throws( + () => cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })), + { code: 'ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY' } + ); +} + +// It throws error if parent directory of symlink in dest points to src. +if (!isInsideDirWithUnusualChars) { + const src = nextdir(); + mkdirSync(join(src, 'a'), mustNotMutateObjectDeep({ recursive: true })); + const dest = nextdir(); + // Create symlink in dest pointing to src. + const destLink = join(dest, 'b'); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(src, destLink); + assert.throws( + () => cpSync(src, join(dest, 'b', 'c')), + { code: 'ERR_FS_CP_EINVAL' } + ); +} + +// It throws error if attempt is made to copy directory to file. +if (!isInsideDirWithUnusualChars) { + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + const dest = join(testDir, 'fixtures/copy/kitchen-sink/README.md'); + assert.throws( + () => cpSync(src, dest), + { code: 'ERR_FS_CP_DIR_TO_NON_DIR' } + ); +} + +// It allows file to be copied to a file path. +{ + const srcFile = join(testDir, 'fixtures/copy/kitchen-sink/index.js'); + const destFile = join(nextdir(), 'index.js'); + cpSync(srcFile, destFile, mustNotMutateObjectDeep({ dereference: true })); + const stat = lstatSync(destFile); + assert(stat.isFile()); +} + +// It throws error if directory copied without recursive flag. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + assert.throws( + () => cpSync(src, dest), + { code: 'ERR_FS_EISDIR' } + ); +} + + +// It throws error if attempt is made to copy file to directory. +if (!isInsideDirWithUnusualChars) { + const src = join(testDir, 'fixtures/copy/kitchen-sink/README.md'); + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + assert.throws( + () => cpSync(src, dest), + { code: 'ERR_FS_CP_NON_DIR_TO_DIR' } + ); +} + +// It must not throw error if attempt is made to copy to dest +// directory with same prefix as src directory +// regression test for https://github.com/nodejs/node/issues/54285 +{ + const src = nextdir('prefix'); + const dest = nextdir('prefix-a'); + mkdirSync(src); + mkdirSync(dest); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); +} + +// It must not throw error if attempt is made to copy to dest +// directory if the parent of dest has same prefix as src directory +// regression test for https://github.com/nodejs/node/issues/54285 +{ + const src = nextdir('aa'); + const destParent = nextdir('aaa'); + const dest = nextdir('aaa/aabb'); + mkdirSync(src); + mkdirSync(destParent); + mkdirSync(dest); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); +} + +// It throws error if attempt is made to copy src to dest +// when src is parent directory of the parent of dest +if (!isInsideDirWithUnusualChars) { + const src = nextdir('a'); + const destParent = nextdir('a/b'); + const dest = nextdir('a/b/c'); + mkdirSync(src); + mkdirSync(destParent); + mkdirSync(dest); + assert.throws( + () => cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })), + { code: 'ERR_FS_CP_EINVAL' }, + ); +} + +// It throws error if attempt is made to copy to subdirectory of self. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = join(testDir, 'fixtures/copy/kitchen-sink/a'); + assert.throws( + () => cpSync(src, dest), + { code: 'ERR_FS_CP_EINVAL' } + ); +} + +// It throws an error if attempt is made to copy socket. +if (!isWindows && !isInsideDirWithUnusualChars) { + const src = nextdir(); + mkdirSync(src); + const dest = nextdir(); + const sock = join(src, `${process.pid}.sock`); + const server = net.createServer(); + server.listen(sock); + assert.throws( + () => cpSync(sock, dest), + { code: 'ERR_FS_CP_SOCKET' } + ); + server.close(); +} + +// It copies timestamps from src to dest if preserveTimestamps is true. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ preserveTimestamps: true, recursive: true })); + assertDirEquivalent(src, dest); + const srcStat = lstatSync(join(src, 'index.js')); + const destStat = lstatSync(join(dest, 'index.js')); + assert.strictEqual(srcStat.mtime.getTime(), destStat.mtime.getTime()); +} + +// It applies filter function. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cpSync(src, dest, { + filter: (path) => { + const pathStat = statSync(path); + return pathStat.isDirectory() || path.endsWith('.js'); + }, + dereference: true, + recursive: true, + }); + const destEntries = []; + collectEntries(dest, destEntries); + for (const entry of destEntries) { + assert.strictEqual( + entry.isDirectory() || entry.name.endsWith('.js'), + true + ); + } +} + +// It throws error if filter function is asynchronous. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + assert.throws(() => { + cpSync(src, dest, { + filter: async (path) => { + await setTimeout(5, 'done'); + const pathStat = statSync(path); + return pathStat.isDirectory() || path.endsWith('.js'); + }, + dereference: true, + recursive: true, + }); + }, { code: 'ERR_INVALID_RETURN_VALUE' }); +} + +// It throws error if errorOnExist is true, force is false, and file or folder +// copied over. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + assert.throws( + () => cpSync(src, dest, { + dereference: true, + errorOnExist: true, + force: false, + recursive: true, + }), + { code: 'ERR_FS_CP_EEXIST' } + ); +} + +// It throws EEXIST error if attempt is made to copy symlink over file. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(dest, 'a', 'c'), 'hello', 'utf8'); + assert.throws( + () => cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })), + { code: 'EEXIST' } + ); +} + +// It throws an error when attempting to copy a file with a name that is too long. +{ + const src = 'a'.repeat(5000); + const dest = nextdir(); + assert.throws( + () => cpSync(src, dest), + { code: isWindows ? 'ENOENT' : 'ENAMETOOLONG' } + ); +} + +// It throws an error when attempting to copy a dir that does not exist. +{ + const src = nextdir(); + const dest = nextdir(); + assert.throws( + () => cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })), + { code: 'ENOENT' } + ); +} + +// It makes file writeable when updating timestamp, if not writeable. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.txt'), 'foo', mustNotMutateObjectDeep({ mode: 0o444 })); + cpSync(src, dest, mustNotMutateObjectDeep({ preserveTimestamps: true, recursive: true })); + assertDirEquivalent(src, dest); + const srcStat = lstatSync(join(src, 'foo.txt')); + const destStat = lstatSync(join(dest, 'foo.txt')); + assert.strictEqual(srcStat.mtime.getTime(), destStat.mtime.getTime()); +} + +// It copies link if it does not point to folder in src. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(src, join(src, 'a', 'c')); + const dest = nextdir(); + mkdirSync(join(dest, 'a'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(dest, join(dest, 'a', 'c')); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + const link = readlinkSync(join(dest, 'a', 'c')); + assert.strictEqual(link, src); +} + +// It accepts file URL as src and dest. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cpSync(pathToFileURL(src), pathToFileURL(dest), mustNotMutateObjectDeep({ recursive: true })); + assertDirEquivalent(src, dest); +} + +// It throws if options is not object. +{ + assert.throws( + () => cpSync('a', 'b', () => {}), + { code: 'ERR_INVALID_ARG_TYPE' } + ); +} + +// Callback implementation of copy. + +// It copies a nested folder structure with files and folders. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + })); +} + +// It copies a nested folder structure with mode flags. +// This test is based on fs.promises.copyFile() with `COPYFILE_FICLONE_FORCE`. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(src, dest, mustNotMutateObjectDeep({ + recursive: true, + mode: fs.constants.COPYFILE_FICLONE_FORCE, + }), mustCall((err) => { + if (!err) { + // If the platform support `COPYFILE_FICLONE_FORCE` operation, + // it should reach to here. + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + return; + } + + // If the platform does not support `COPYFILE_FICLONE_FORCE` operation, + // it should enter this path. + assert.strictEqual(err.syscall, 'copyfile'); + assert(err.code === 'ENOTSUP' || err.code === 'ENOTTY' || + err.code === 'ENOSYS' || err.code === 'EXDEV'); + })); +} + +// It does not throw errors when directory is copied over and force is false. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'README.md'), 'hello world', 'utf8'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ dereference: true, recursive: true })); + const initialStat = lstatSync(join(dest, 'README.md')); + cp(src, dest, { + dereference: true, + force: false, + recursive: true, + }, mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + // File should not have been copied over, so access times will be identical: + const finalStat = lstatSync(join(dest, 'README.md')); + assert.strictEqual(finalStat.ctime.getTime(), initialStat.ctime.getTime()); + })); +} + +// It overwrites existing files if force is true. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(dest, 'README.md'), '# Goodbye', 'utf8'); + + cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + const content = readFileSync(join(dest, 'README.md'), 'utf8'); + assert.strictEqual(content.trim(), '# Hello'); + })); +} + +// It does not fail if the same directory is copied to dest twice, +// when dereference is true, and force is false (fails silently). +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + const destFile = join(dest, 'a/b/README2.md'); + cpSync(src, dest, mustNotMutateObjectDeep({ dereference: true, recursive: true })); + cp(src, dest, { + dereference: true, + recursive: true + }, mustCall((err) => { + assert.strictEqual(err, null); + const stat = lstatSync(destFile); + assert(stat.isFile()); + })); +} + +// It copies file itself, rather than symlink, when dereference is true. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync(join(src, 'foo.js'), join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + const destFile = join(dest, 'foo.js'); + + cp(join(src, 'bar.js'), destFile, mustNotMutateObjectDeep({ dereference: true }), + mustCall((err) => { + assert.strictEqual(err, null); + const stat = lstatSync(destFile); + assert(stat.isFile()); + }) + ); +} + +// It returns error when src and dest are identical. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + cp(src, src, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_EINVAL'); + })); +} + +// It returns error if symlink in src points to location in dest. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + const dest = nextdir(); + mkdirSync(dest); + symlinkSync(dest, join(src, 'link')); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_EINVAL'); + })); +} + +// It returns error if symlink in dest points to location in src. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(src, join(dest, 'a', 'c')); + cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY'); + })); +} + +// It returns error if parent directory of symlink in dest points to src. +{ + const src = nextdir(); + mkdirSync(join(src, 'a'), mustNotMutateObjectDeep({ recursive: true })); + const dest = nextdir(); + // Create symlink in dest pointing to src. + const destLink = join(dest, 'b'); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(src, destLink); + cp(src, join(dest, 'b', 'c'), mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_EINVAL'); + })); +} + +// It returns error if attempt is made to copy directory to file. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + const dest = join(testDir, 'fixtures/copy/kitchen-sink/README.md'); + cp(src, dest, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_DIR_TO_NON_DIR'); + })); +} + +// It allows file to be copied to a file path. +{ + const srcFile = join(testDir, 'fixtures/copy/kitchen-sink/README.md'); + const destFile = join(nextdir(), 'index.js'); + cp(srcFile, destFile, mustNotMutateObjectDeep({ dereference: true }), mustCall((err) => { + assert.strictEqual(err, null); + const stat = lstatSync(destFile); + assert(stat.isFile()); + })); +} + +// It returns error if directory copied without recursive flag. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(src, dest, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_EISDIR'); + })); +} + +// It returns error if attempt is made to copy file to directory. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink/README.md'); + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + cp(src, dest, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_NON_DIR_TO_DIR'); + })); +} + +// It returns error if attempt is made to copy to subdirectory of self. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = join(testDir, 'fixtures/copy/kitchen-sink/a'); + cp(src, dest, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_EINVAL'); + })); +} + +// It returns an error if attempt is made to copy socket. +if (!isWindows && !isInsideDirWithUnusualChars) { + const src = nextdir(); + mkdirSync(src); + const dest = nextdir(); + const sock = join(src, `${process.pid}.sock`); + const server = net.createServer(); + server.listen(sock); + cp(sock, dest, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_SOCKET'); + server.close(); + })); +} + +// It copies timestamps from src to dest if preserveTimestamps is true. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(src, dest, { + preserveTimestamps: true, + recursive: true + }, mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + const srcStat = lstatSync(join(src, 'index.js')); + const destStat = lstatSync(join(dest, 'index.js')); + assert.strictEqual(srcStat.mtime.getTime(), destStat.mtime.getTime()); + })); +} + +// It applies filter function. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(src, dest, { + filter: (path) => { + const pathStat = statSync(path); + return pathStat.isDirectory() || path.endsWith('.js'); + }, + dereference: true, + recursive: true, + }, mustCall((err) => { + assert.strictEqual(err, null); + const destEntries = []; + collectEntries(dest, destEntries); + for (const entry of destEntries) { + assert.strictEqual( + entry.isDirectory() || entry.name.endsWith('.js'), + true + ); + } + })); +} + +// It supports async filter function. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(src, dest, { + filter: async (path) => { + await setTimeout(5, 'done'); + const pathStat = statSync(path); + return pathStat.isDirectory() || path.endsWith('.js'); + }, + dereference: true, + recursive: true, + }, mustCall((err) => { + assert.strictEqual(err, null); + const destEntries = []; + collectEntries(dest, destEntries); + for (const entry of destEntries) { + assert.strictEqual( + entry.isDirectory() || entry.name.endsWith('.js'), + true + ); + } + })); +} + +// It returns error if errorOnExist is true, force is false, and file or folder +// copied over. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true })); + cp(src, dest, { + dereference: true, + errorOnExist: true, + force: false, + recursive: true, + }, mustCall((err) => { + assert.strictEqual(err.code, 'ERR_FS_CP_EEXIST'); + })); +} + +// It returns EEXIST error if attempt is made to copy symlink over file. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(dest, 'a', 'c'), 'hello', 'utf8'); + cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => { + assert.strictEqual(err.code, 'EEXIST'); + })); +} + +// It makes file writeable when updating timestamp, if not writeable. +{ + const src = nextdir(); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(src, 'foo.txt'), 'foo', mustNotMutateObjectDeep({ mode: 0o444 })); + cp(src, dest, { + preserveTimestamps: true, + recursive: true, + }, mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + const srcStat = lstatSync(join(src, 'foo.txt')); + const destStat = lstatSync(join(dest, 'foo.txt')); + assert.strictEqual(srcStat.mtime.getTime(), destStat.mtime.getTime()); + })); +} + +// It copies link if it does not point to folder in src. +{ + const src = nextdir(); + mkdirSync(join(src, 'a', 'b'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(src, join(src, 'a', 'c')); + const dest = nextdir(); + mkdirSync(join(dest, 'a'), mustNotMutateObjectDeep({ recursive: true })); + symlinkSync(dest, join(dest, 'a', 'c')); + cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => { + assert.strictEqual(err, null); + const link = readlinkSync(join(dest, 'a', 'c')); + assert.strictEqual(link, src); + })); +} + +// It accepts file URL as src and dest. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + cp(pathToFileURL(src), pathToFileURL(dest), mustNotMutateObjectDeep({ recursive: true }), + mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + })); +} + +// Copy should not throw exception if child folder is filtered out. +{ + const src = nextdir(); + mkdirSync(join(src, 'test-cp'), mustNotMutateObjectDeep({ recursive: true })); + + const dest = nextdir(); + mkdirSync(dest, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(join(dest, 'test-cp'), 'test-content', mustNotMutateObjectDeep({ mode: 0o444 })); + + const opts = { + filter: (path) => !path.includes('test-cp'), + recursive: true, + }; + cp(src, dest, opts, mustCall((err) => { + assert.strictEqual(err, null); + })); + cpSync(src, dest, opts); +} + +// Copy should not throw exception if dest is invalid but filtered out. +{ + // Create dest as a file. + // Expect: cp skips the copy logic entirely and won't throw any exception in path validation process. + const src = join(nextdir(), 'bar'); + mkdirSync(src, mustNotMutateObjectDeep({ recursive: true })); + + const destParent = nextdir(); + const dest = join(destParent, 'bar'); + mkdirSync(destParent, mustNotMutateObjectDeep({ recursive: true })); + writeFileSync(dest, 'test-content', mustNotMutateObjectDeep({ mode: 0o444 })); + + const opts = { + filter: (path) => !path.includes('bar'), + recursive: true, + }; + cp(src, dest, opts, mustCall((err) => { + assert.strictEqual(err, null); + })); + cpSync(src, dest, opts); +} + +// It throws if options is not object. +{ + assert.throws( + () => cp('a', 'b', 'hello', () => {}), + { code: 'ERR_INVALID_ARG_TYPE' } + ); +} + +// It throws if options is not object. +{ + assert.throws( + () => cp('a', 'b', { mode: -1 }, () => {}), + { code: 'ERR_OUT_OF_RANGE' } + ); +} + +// Promises implementation of copy. + +// It copies a nested folder structure with files and folders. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + const p = await fs.promises.cp(src, dest, mustNotMutateObjectDeep({ recursive: true })); + assert.strictEqual(p, undefined); + assertDirEquivalent(src, dest); +} + +// It copies a nested folder structure with mode flags. +// This test is based on fs.promises.copyFile() with `COPYFILE_FICLONE_FORCE`. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + let p = null; + let successFiClone = false; + try { + p = await fs.promises.cp(src, dest, mustNotMutateObjectDeep({ + recursive: true, + mode: fs.constants.COPYFILE_FICLONE_FORCE, + })); + successFiClone = true; + } catch (err) { + // If the platform does not support `COPYFILE_FICLONE_FORCE` operation, + // it should enter this path. + assert.strictEqual(err.syscall, 'copyfile'); + assert(err.code === 'ENOTSUP' || err.code === 'ENOTTY' || + err.code === 'ENOSYS' || err.code === 'EXDEV'); + } + + if (successFiClone) { + // If the platform support `COPYFILE_FICLONE_FORCE` operation, + // it should reach to here. + assert.strictEqual(p, undefined); + assertDirEquivalent(src, dest); + } +} + +// It accepts file URL as src and dest. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + const p = await fs.promises.cp( + pathToFileURL(src), + pathToFileURL(dest), + { recursive: true } + ); + assert.strictEqual(p, undefined); + assertDirEquivalent(src, dest); +} + +// It allows async error to be caught. +{ + const src = join(testDir, 'fixtures/copy/kitchen-sink'); + const dest = nextdir(); + await fs.promises.cp(src, dest, mustNotMutateObjectDeep({ recursive: true })); + await assert.rejects( + fs.promises.cp(src, dest, { + dereference: true, + errorOnExist: true, + force: false, + recursive: true, + }), + { code: 'ERR_FS_CP_EEXIST' } + ); +} + +// It rejects if options is not object. +{ + await assert.rejects( + fs.promises.cp('a', 'b', () => {}), + { code: 'ERR_INVALID_ARG_TYPE' } + ); +} + +// It rejects if options.mode is invalid. +{ + await assert.rejects( + fs.promises.cp('a', 'b', { + mode: -1, + }), + { code: 'ERR_OUT_OF_RANGE' } + ); +} + +function assertDirEquivalent(dir1, dir2) { + const dir1Entries = []; + collectEntries(dir1, dir1Entries); + const dir2Entries = []; + collectEntries(dir2, dir2Entries); + assert.strictEqual(dir1Entries.length, dir2Entries.length); + for (const entry1 of dir1Entries) { + const entry2 = dir2Entries.find((entry) => { + return entry.name === entry1.name; + }); + assert(entry2, `entry ${entry2.name} not copied`); + if (entry1.isFile()) { + assert(entry2.isFile(), `${entry2.name} was not file`); + } else if (entry1.isDirectory()) { + assert(entry2.isDirectory(), `${entry2.name} was not directory`); + } else if (entry1.isSymbolicLink()) { + assert(entry2.isSymbolicLink(), `${entry2.name} was not symlink`); + } + } +} + +function collectEntries(dir, dirEntries) { + const newEntries = readdirSync(dir, mustNotMutateObjectDeep({ withFileTypes: true })); + for (const entry of newEntries) { + if (entry.isDirectory()) { + collectEntries(join(dir, entry.name), dirEntries); + } + } + dirEntries.push(...newEntries); +}