diff options
author | bcoe <bencoe@google.com> | 2021-08-08 13:48:01 -0700 |
---|---|---|
committer | Benjamin Coe <bencoe@google.com> | 2021-08-11 19:53:32 -0700 |
commit | 87d6fd7e696ee02178a8dc33a51e8e59bdc61d68 (patch) | |
tree | e9c76b5f1d9f4ba4f51528b2ef8e41b82b6e2f4f /lib | |
parent | 4ece669c6205ec78abfdadfe78869bbb8411463e (diff) | |
download | node-new-87d6fd7e696ee02178a8dc33a51e8e59bdc61d68.tar.gz |
fs: add recursive cp method
Introduces recursive cp method, based on fs-extra implementation.
PR-URL: https://github.com/nodejs/node/pull/39372
Fixes: https://github.com/nodejs/node/issues/35880
Refs: https://github.com/nodejs/tooling/issues/98
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Michaƫl Zasso <targos@protonmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Ian Sutherland <ian@iansutherland.ca>
Diffstat (limited to 'lib')
-rw-r--r-- | lib/fs.js | 50 | ||||
-rw-r--r-- | lib/internal/errors.js | 11 | ||||
-rw-r--r-- | lib/internal/fs/cp/cp-sync.js | 331 | ||||
-rw-r--r-- | lib/internal/fs/cp/cp.js | 384 | ||||
-rw-r--r-- | lib/internal/fs/promises.js | 16 | ||||
-rw-r--r-- | lib/internal/fs/utils.js | 27 |
6 files changed, 818 insertions, 1 deletions
@@ -107,6 +107,7 @@ const { stringToSymlinkType, toUnixTimestamp, validateBufferArray, + validateCpOptions, validateOffsetLengthRead, validateOffsetLengthWrite, validatePath, @@ -145,6 +146,8 @@ let truncateWarn = true; let fs; // Lazy loaded +let cpFn; +let cpSyncFn; let promises = null; let ReadStream; let WriteStream; @@ -1075,6 +1078,13 @@ function ftruncateSync(fd, len = 0) { handleErrorFromBinding(ctx); } +function lazyLoadCp() { + if (cpFn === undefined) { + ({ cpFn } = require('internal/fs/cp/cp')); + cpFn = require('util').callbackify(cpFn); + ({ cpSyncFn } = require('internal/fs/cp/cp-sync')); + } +} function lazyLoadRimraf() { if (rimraf === undefined) @@ -2790,6 +2800,44 @@ function copyFileSync(src, dest, mode) { handleErrorFromBinding(ctx); } +/** + * Asynchronously copies `src` to `dest`. `src` can be a file, directory, or + * symlink. The contents of directories will be copied recursively. + * @param {string | URL} src + * @param {string | URL} dest + * @param {Object} [options] + * @param {() => any} callback + * @returns {void} + */ +function cp(src, dest, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + callback = makeCallback(callback); + options = validateCpOptions(options); + src = pathModule.toNamespacedPath(getValidatedPath(src, 'src')); + dest = pathModule.toNamespacedPath(getValidatedPath(dest, 'dest')); + lazyLoadCp(); + cpFn(src, dest, options, callback); +} + +/** + * Synchronously copies `src` to `dest`. `src` can be a file, directory, or + * symlink. The contents of directories will be copied recursively. + * @param {string | URL} src + * @param {string | URL} dest + * @param {Object} [options] + * @returns {void} + */ +function cpSync(src, dest, options) { + options = validateCpOptions(options); + src = pathModule.toNamespacedPath(getValidatedPath(src, 'src')); + dest = pathModule.toNamespacedPath(getValidatedPath(dest, 'dest')); + lazyLoadCp(); + cpSyncFn(src, dest, options); +} + function lazyLoadStreams() { if (!ReadStream) { ({ ReadStream, WriteStream } = require('internal/fs/streams')); @@ -2854,6 +2902,8 @@ module.exports = fs = { closeSync, copyFile, copyFileSync, + cp, + cpSync, createReadStream, createWriteStream, exists, diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 25b847b140..7541d7d1eb 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -961,6 +961,17 @@ E('ERR_FEATURE_UNAVAILABLE_ON_PLATFORM', 'The feature %s is unavailable on the current platform' + ', which is being used to run Node.js', TypeError); +E('ERR_FS_CP_DIR_TO_NON_DIR', + 'Cannot overwrite directory with non-directory', SystemError); +E('ERR_FS_CP_EEXIST', 'Target already exists', SystemError); +E('ERR_FS_CP_EINVAL', 'Invalid src or dest', SystemError); +E('ERR_FS_CP_FIFO_PIPE', 'Cannot copy a FIFO pipe', SystemError); +E('ERR_FS_CP_NON_DIR_TO_DIR', + 'Cannot overwrite non-directory with directory', SystemError); +E('ERR_FS_CP_SOCKET', 'Cannot copy a socket file', SystemError); +E('ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY', + 'Cannot overwrite symlink in subdirectory of self', SystemError); +E('ERR_FS_CP_UNKNOWN', 'Cannot copy an unknown file type', SystemError); E('ERR_FS_EISDIR', 'Path is a directory', SystemError); E('ERR_FS_FILE_TOO_LARGE', 'File size (%s) is greater than 2 GB', RangeError); E('ERR_FS_INVALID_SYMLINK_TYPE', diff --git a/lib/internal/fs/cp/cp-sync.js b/lib/internal/fs/cp/cp-sync.js new file mode 100644 index 0000000000..ca2102aeb5 --- /dev/null +++ b/lib/internal/fs/cp/cp-sync.js @@ -0,0 +1,331 @@ +'use strict'; + +// This file is a modified version of the fs-extra's copySync method. + +const { areIdentical, isSrcSubdir } = require('internal/fs/cp/cp'); +const { codes } = require('internal/errors'); +const { + os: { + errno: { + EEXIST, + EISDIR, + EINVAL, + ENOTDIR, + } + } +} = internalBinding('constants'); +const { + ERR_FS_CP_DIR_TO_NON_DIR, + ERR_FS_CP_EEXIST, + ERR_FS_CP_EINVAL, + ERR_FS_CP_FIFO_PIPE, + ERR_FS_CP_NON_DIR_TO_DIR, + ERR_FS_CP_SOCKET, + ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY, + ERR_FS_CP_UNKNOWN, + ERR_FS_EISDIR, + ERR_INVALID_RETURN_VALUE, +} = codes; +const fs = require('fs'); +const { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readlinkSync, + statSync, + symlinkSync, + unlinkSync, + utimesSync, +} = fs; +const path = require('path'); +const { + dirname, + isAbsolute, + join, + parse, + resolve, +} = path; +const { isPromise } = require('util/types'); + +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 } = checkPathsSync(src, dest, opts); + checkParentPathsSync(src, srcStat, dest); + return handleFilterAndCopy(destStat, src, dest, opts); +} + +function checkPathsSync(src, dest, opts) { + const { srcStat, destStat } = getStatsSync(src, dest, opts); + + if (destStat) { + if (areIdentical(srcStat, destStat)) { + throw new ERR_FS_CP_EINVAL({ + message: 'src and dest cannot be the same', + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + if (srcStat.isDirectory() && !destStat.isDirectory()) { + throw new ERR_FS_CP_DIR_TO_NON_DIR({ + message: `cannot overwrite directory ${src} ` + + `with non-directory ${dest}`, + path: dest, + syscall: 'cp', + errno: EISDIR, + }); + } + if (!srcStat.isDirectory() && destStat.isDirectory()) { + throw new ERR_FS_CP_NON_DIR_TO_DIR({ + message: `cannot overwrite non-directory ${src} ` + + `with directory ${dest}`, + path: dest, + syscall: 'cp', + errno: ENOTDIR, + }); + } + } + + if (srcStat.isDirectory() && isSrcSubdir(src, dest)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${src} to a subdirectory of self ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + return { srcStat, destStat }; +} + +function getStatsSync(src, dest, opts) { + let destStat; + const statFunc = opts.dereference ? + (file) => statSync(file, { bigint: true }) : + (file) => lstatSync(file, { bigint: true }); + const srcStat = statFunc(src); + try { + destStat = statFunc(dest); + } catch (err) { + if (err.code === 'ENOENT') return { srcStat, destStat: null }; + throw err; + } + return { srcStat, destStat }; +} + +function checkParentPathsSync(src, srcStat, dest) { + const srcParent = resolve(dirname(src)); + const destParent = resolve(dirname(dest)); + if (destParent === srcParent || destParent === parse(destParent).root) return; + let destStat; + try { + destStat = statSync(destParent, { bigint: true }); + } catch (err) { + if (err.code === 'ENOENT') return; + throw err; + } + if (areIdentical(srcStat, destStat)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${src} to a subdirectory of self ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + return checkParentPathsSync(src, srcStat, destParent); +} + +function handleFilterAndCopy(destStat, src, dest, opts) { + if (opts.filter) { + const shouldCopy = opts.filter(src, dest); + if (isPromise(shouldCopy)) { + throw new ERR_INVALID_RETURN_VALUE('boolean', 'filter', shouldCopy); + } + if (!shouldCopy) return; + } + const destParent = dirname(dest); + if (!existsSync(destParent)) mkdirSync(destParent, { recursive: true }); + return getStats(destStat, src, dest, opts); +} + +function startCopy(destStat, src, dest, opts) { + if (opts.filter && !opts.filter(src, dest)) return; + return getStats(destStat, src, dest, opts); +} + +function getStats(destStat, src, dest, opts) { + const statSyncFn = opts.dereference ? statSync : lstatSync; + const srcStat = statSyncFn(src); + + if (srcStat.isDirectory() && opts.recursive) { + return onDir(srcStat, destStat, src, dest, opts); + } else if (srcStat.isDirectory()) { + throw new ERR_FS_EISDIR({ + message: `${src} is a directory (not copied)`, + path: src, + syscall: 'cp', + errno: EINVAL, + }); + } else if (srcStat.isFile() || + srcStat.isCharacterDevice() || + srcStat.isBlockDevice()) { + return onFile(srcStat, destStat, src, dest, opts); + } else if (srcStat.isSymbolicLink()) { + return onLink(destStat, src, dest); + } else if (srcStat.isSocket()) { + throw new ERR_FS_CP_SOCKET({ + message: `cannot copy a socket file: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } else if (srcStat.isFIFO()) { + throw new ERR_FS_CP_FIFO_PIPE({ + message: `cannot copy a FIFO pipe: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + throw new ERR_FS_CP_UNKNOWN({ + message: `cannot copy an unknown file type: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); +} + +function onFile(srcStat, destStat, src, dest, opts) { + if (!destStat) return copyFile(srcStat, src, dest, opts); + return mayCopyFile(srcStat, src, dest, opts); +} + +function mayCopyFile(srcStat, src, dest, opts) { + if (opts.force) { + unlinkSync(dest); + return copyFile(srcStat, src, dest, opts); + } else if (opts.errorOnExist) { + throw new ERR_FS_CP_EEXIST({ + message: `${dest} already exists`, + path: dest, + syscall: 'cp', + errno: EEXIST, + }); + } +} + +function copyFile(srcStat, src, dest, opts) { + copyFileSync(src, dest); + if (opts.preserveTimestamps) handleTimestamps(srcStat.mode, src, dest); + return setDestMode(dest, srcStat.mode); +} + +function handleTimestamps(srcMode, src, dest) { + // Make sure the file is writable before setting the timestamp + // otherwise open fails with EPERM when invoked with 'r+' + // (through utimes call) + if (fileIsNotWritable(srcMode)) makeFileWritable(dest, srcMode); + return setDestTimestamps(src, dest); +} + +function fileIsNotWritable(srcMode) { + return (srcMode & 0o200) === 0; +} + +function makeFileWritable(dest, srcMode) { + return setDestMode(dest, srcMode | 0o200); +} + +function setDestMode(dest, srcMode) { + return chmodSync(dest, srcMode); +} + +function setDestTimestamps(src, dest) { + // The initial srcStat.atime cannot be trusted + // because it is modified by the read(2) system call + // (See https://nodejs.org/api/fs.html#fs_stat_time_values) + const updatedSrcStat = statSync(src); + return utimesSync(dest, updatedSrcStat.atime, updatedSrcStat.mtime); +} + +function onDir(srcStat, destStat, src, dest, opts) { + if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts); + return copyDir(src, dest, opts); +} + +function mkDirAndCopy(srcMode, src, dest, opts) { + mkdirSync(dest); + copyDir(src, dest, opts); + return setDestMode(dest, srcMode); +} + +function copyDir(src, dest, opts) { + readdirSync(src).forEach((item) => copyDirItem(item, src, dest, opts)); +} + +function copyDirItem(item, src, dest, opts) { + const srcItem = join(src, item); + const destItem = join(dest, item); + const { destStat } = checkPathsSync(srcItem, destItem, opts); + return startCopy(destStat, srcItem, destItem, opts); +} + +function onLink(destStat, src, dest) { + let resolvedSrc = readlinkSync(src); + if (!isAbsolute(resolvedSrc)) { + resolvedSrc = resolve(dirname(src), resolvedSrc); + } + if (!destStat) { + return symlinkSync(resolvedSrc, dest); + } + let resolvedDest; + try { + resolvedDest = readlinkSync(dest); + } catch (err) { + // Dest exists and is a regular file or directory, + // Windows may throw UNKNOWN error. If dest already exists, + // fs throws error anyway, so no need to guard against it here. + if (err.code === 'EINVAL' || err.code === 'UNKNOWN') { + return symlinkSync(resolvedSrc, dest); + } + throw err; + } + if (!isAbsolute(resolvedDest)) { + resolvedDest = resolve(dirname(dest), resolvedDest); + } + if (isSrcSubdir(resolvedSrc, resolvedDest)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${resolvedSrc} to a subdirectory of self ` + + `${resolvedDest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + // Prevent copy if src is a subdir of dest since unlinking + // dest in this case would result in removing src contents + // and therefore a broken symlink would be created. + if (statSync(dest).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) { + throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({ + message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + return copyLink(resolvedSrc, dest); +} + +function copyLink(resolvedSrc, dest) { + unlinkSync(dest); + return symlinkSync(resolvedSrc, dest); +} + +module.exports = { cpSyncFn }; diff --git a/lib/internal/fs/cp/cp.js b/lib/internal/fs/cp/cp.js new file mode 100644 index 0000000000..9ee661be68 --- /dev/null +++ b/lib/internal/fs/cp/cp.js @@ -0,0 +1,384 @@ +'use strict'; + +// This file is a modified version of the fs-extra's copy method. + +const { + ArrayPrototypeEvery, + ArrayPrototypeFilter, + Boolean, + PromiseAll, + PromisePrototypeCatch, + PromisePrototypeThen, + PromiseReject, + SafeArrayIterator, + StringPrototypeSplit, +} = primordials; +const { + codes: { + ERR_FS_CP_DIR_TO_NON_DIR, + ERR_FS_CP_EEXIST, + ERR_FS_CP_EINVAL, + ERR_FS_CP_FIFO_PIPE, + ERR_FS_CP_NON_DIR_TO_DIR, + ERR_FS_CP_SOCKET, + ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY, + ERR_FS_CP_UNKNOWN, + ERR_FS_EISDIR, + }, +} = require('internal/errors'); +const { + os: { + errno: { + EEXIST, + EISDIR, + EINVAL, + ENOTDIR, + } + } +} = internalBinding('constants'); +const { + chmod, + copyFile, + lstat, + mkdir, + readdir, + readlink, + stat, + symlink, + unlink, + utimes, +} = require('fs/promises'); +const { + dirname, + isAbsolute, + join, + parse, + resolve, + sep, +} = require('path'); + +async function cpFn(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 stats = await checkPaths(src, dest, opts); + const { srcStat, destStat } = stats; + await checkParentPaths(src, srcStat, dest); + if (opts.filter) { + return handleFilter(checkParentDir, destStat, src, dest, opts); + } + return checkParentDir(destStat, src, dest, opts); +} + +async function checkPaths(src, dest, opts) { + const { 0: srcStat, 1: destStat } = await getStats(src, dest, opts); + if (destStat) { + if (areIdentical(srcStat, destStat)) { + throw new ERR_FS_CP_EINVAL({ + message: 'src and dest cannot be the same', + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + if (srcStat.isDirectory() && !destStat.isDirectory()) { + throw new ERR_FS_CP_DIR_TO_NON_DIR({ + message: `cannot overwrite directory ${src} ` + + `with non-directory ${dest}`, + path: dest, + syscall: 'cp', + errno: EISDIR, + }); + } + if (!srcStat.isDirectory() && destStat.isDirectory()) { + throw new ERR_FS_CP_NON_DIR_TO_DIR({ + message: `cannot overwrite non-directory ${src} ` + + `with directory ${dest}`, + path: dest, + syscall: 'cp', + errno: ENOTDIR, + }); + } + } + + if (srcStat.isDirectory() && isSrcSubdir(src, dest)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${src} to a subdirectory of self ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + return { srcStat, destStat }; +} + +function areIdentical(srcStat, destStat) { + return destStat.ino && destStat.dev && destStat.ino === srcStat.ino && + destStat.dev === srcStat.dev; +} + +function getStats(src, dest, opts) { + const statFunc = opts.dereference ? + (file) => stat(file, { bigint: true }) : + (file) => lstat(file, { bigint: true }); + return PromiseAll(new SafeArrayIterator([ + statFunc(src), + PromisePrototypeCatch(statFunc(dest), (err) => { + if (err.code === 'ENOENT') return null; + throw err; + }), + ])); +} + +async function checkParentDir(destStat, src, dest, opts) { + const destParent = dirname(dest); + const dirExists = await pathExists(destParent); + if (dirExists) return getStatsForCopy(destStat, src, dest, opts); + await mkdir(destParent, { recursive: true }); + return getStatsForCopy(destStat, src, dest, opts); +} + +function pathExists(dest) { + return PromisePrototypeThen( + stat(dest), + () => true, + (err) => (err.code === 'ENOENT' ? false : PromiseReject(err))); +} + +// Recursively check if dest parent is a subdirectory of src. +// It works for all file types including symlinks since it +// checks the src and dest inodes. It starts from the deepest +// parent and stops once it reaches the src parent or the root path. +async function checkParentPaths(src, srcStat, dest) { + const srcParent = resolve(dirname(src)); + const destParent = resolve(dirname(dest)); + if (destParent === srcParent || destParent === parse(destParent).root) { + return; + } + let destStat; + try { + destStat = await stat(destParent, { bigint: true }); + } catch (err) { + if (err.code === 'ENOENT') return; + throw err; + } + if (areIdentical(srcStat, destStat)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${src} to a subdirectory of self ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + return checkParentPaths(src, srcStat, destParent); +} + +const normalizePathToArray = (path) => + ArrayPrototypeFilter(StringPrototypeSplit(resolve(path), sep), Boolean); + +// Return true if dest is a subdir of src, otherwise false. +// It only checks the path strings. +function isSrcSubdir(src, dest) { + const srcArr = normalizePathToArray(src); + const destArr = normalizePathToArray(dest); + return ArrayPrototypeEvery(srcArr, (cur, i) => destArr[i] === cur); +} + +async function handleFilter(onInclude, destStat, src, dest, opts, cb) { + const include = await opts.filter(src, dest); + if (include) return onInclude(destStat, src, dest, opts, cb); +} + +function startCopy(destStat, src, dest, opts) { + if (opts.filter) { + return handleFilter(getStatsForCopy, destStat, src, dest, opts); + } + return getStatsForCopy(destStat, src, dest, opts); +} + +async function getStatsForCopy(destStat, src, dest, opts) { + const statFn = opts.dereference ? stat : lstat; + const srcStat = await statFn(src); + if (srcStat.isDirectory() && opts.recursive) { + return onDir(srcStat, destStat, src, dest, opts); + } else if (srcStat.isDirectory()) { + throw new ERR_FS_EISDIR({ + message: `${src} is a directory (not copied)`, + path: src, + syscall: 'cp', + errno: EINVAL, + }); + } else if (srcStat.isFile() || + srcStat.isCharacterDevice() || + srcStat.isBlockDevice()) { + return onFile(srcStat, destStat, src, dest, opts); + } else if (srcStat.isSymbolicLink()) { + return onLink(destStat, src, dest); + } else if (srcStat.isSocket()) { + throw new ERR_FS_CP_SOCKET({ + message: `cannot copy a socket file: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } else if (srcStat.isFIFO()) { + throw new ERR_FS_CP_FIFO_PIPE({ + message: `cannot copy a FIFO pipe: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + throw new ERR_FS_CP_UNKNOWN({ + message: `cannot copy an unknown file type: ${dest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); +} + +function onFile(srcStat, destStat, src, dest, opts) { + if (!destStat) return _copyFile(srcStat, src, dest, opts); + return mayCopyFile(srcStat, src, dest, opts); +} + +async function mayCopyFile(srcStat, src, dest, opts) { + if (opts.force) { + await unlink(dest); + return _copyFile(srcStat, src, dest, opts); + } else if (opts.errorOnExist) { + throw new ERR_FS_CP_EEXIST({ + message: `${dest} already exists`, + path: dest, + syscall: 'cp', + errno: EEXIST, + }); + } +} + +async function _copyFile(srcStat, src, dest, opts) { + await copyFile(src, dest); + if (opts.preserveTimestamps) { + return handleTimestampsAndMode(srcStat.mode, src, dest); + } + return setDestMode(dest, srcStat.mode); +} + +async function handleTimestampsAndMode(srcMode, src, dest) { + // Make sure the file is writable before setting the timestamp + // otherwise open fails with EPERM when invoked with 'r+' + // (through utimes call) + if (fileIsNotWritable(srcMode)) { + await makeFileWritable(dest, srcMode); + return setDestTimestampsAndMode(srcMode, src, dest); + } + return setDestTimestampsAndMode(srcMode, src, dest); +} + +function fileIsNotWritable(srcMode) { + return (srcMode & 0o200) === 0; +} + +function makeFileWritable(dest, srcMode) { + return setDestMode(dest, srcMode | 0o200); +} + +async function setDestTimestampsAndMode(srcMode, src, dest) { + await setDestTimestamps(src, dest); + return setDestMode(dest, srcMode); +} + +function setDestMode(dest, srcMode) { + return chmod(dest, srcMode); +} + +async function setDestTimestamps(src, dest) { + // The initial srcStat.atime cannot be trusted + // because it is modified by the read(2) system call + // (See https://nodejs.org/api/fs.html#fs_stat_time_values) + const updatedSrcStat = await stat(src); + return utimes(dest, updatedSrcStat.atime, updatedSrcStat.mtime); +} + +function onDir(srcStat, destStat, src, dest, opts) { + if (!destStat) return mkDirAndCopy(srcStat.mode, src, dest, opts); + return copyDir(src, dest, opts); +} + +async function mkDirAndCopy(srcMode, src, dest, opts) { + await mkdir(dest); + await copyDir(src, dest, opts); + return setDestMode(dest, srcMode); +} + +async function copyDir(src, dest, opts) { + const dir = await readdir(src); + for (let i = 0; i < dir.length; i++) { + const item = dir[i]; + const srcItem = join(src, item); + const destItem = join(dest, item); + const { destStat } = await checkPaths(srcItem, destItem, opts); + await startCopy(destStat, srcItem, destItem, opts); + } +} + +async function onLink(destStat, src, dest) { + let resolvedSrc = await readlink(src); + if (!isAbsolute(resolvedSrc)) { + resolvedSrc = resolve(dirname(src), resolvedSrc); + } + if (!destStat) { + return symlink(resolvedSrc, dest); + } + let resolvedDest; + try { + resolvedDest = await readlink(dest); + } catch (err) { + // Dest exists and is a regular file or directory, + // Windows may throw UNKNOWN error. If dest already exists, + // fs throws error anyway, so no need to guard against it here. + if (err.code === 'EINVAL' || err.code === 'UNKNOWN') { + return symlink(resolvedSrc, dest); + } + throw err; + } + if (!isAbsolute(resolvedDest)) { + resolvedDest = resolve(dirname(dest), resolvedDest); + } + if (isSrcSubdir(resolvedSrc, resolvedDest)) { + throw new ERR_FS_CP_EINVAL({ + message: `cannot copy ${resolvedSrc} to a subdirectory of self ` + + `${resolvedDest}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + // Do not copy if src is a subdir of dest since unlinking + // dest in this case would result in removing src contents + // and therefore a broken symlink would be created. + const srcStat = await stat(src); + if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) { + throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({ + message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`, + path: dest, + syscall: 'cp', + errno: EINVAL, + }); + } + return copyLink(resolvedSrc, dest); +} + +async function copyLink(resolvedSrc, dest) { + await unlink(dest); + return symlink(resolvedSrc, dest); +} + +module.exports = { + areIdentical, + cpFn, + isSrcSubdir, +}; diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 692d6b42fa..e26f1314d1 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -59,12 +59,13 @@ const { stringToSymlinkType, toUnixTimestamp, validateBufferArray, + validateCpOptions, validateOffsetLengthRead, validateOffsetLengthWrite, validateRmOptions, validateRmdirOptions, validateStringAfterArrayBufferView, - warnOnNonPortableTemplate + warnOnNonPortableTemplate, } = require('internal/fs/utils'); const { opendir } = require('internal/fs/dir'); const { @@ -109,6 +110,11 @@ const { const getDirectoryEntriesPromise = promisify(getDirents); const validateRmOptionsPromise = promisify(validateRmOptions); +let cpPromises; +function lazyLoadCpPromises() { + return cpPromises ??= require('internal/fs/cp/cp').cpFn; +} + class FileHandle extends EventEmitterMixin(JSTransferable) { /** * @param {InternalFSBinding.FileHandle | undefined} filehandle @@ -433,6 +439,13 @@ async function access(path, mode = F_OK) { kUsePromises); } +async function cp(src, dest, options) { + options = validateCpOptions(options); + src = pathModule.toNamespacedPath(getValidatedPath(src, 'src')); + dest = pathModule.toNamespacedPath(getValidatedPath(dest, 'dest')); + return lazyLoadCpPromises()(src, dest, options); +} + async function copyFile(src, dest, mode) { src = getValidatedPath(src, 'src'); dest = getValidatedPath(dest, 'dest'); @@ -799,6 +812,7 @@ module.exports = { exports: { access, copyFile, + cp, open, opendir: promisify(opendir), rename, diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index 322b1519ef..b0aeb8185e 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -47,6 +47,7 @@ const { toPathIfFileURL } = require('internal/url'); const { validateAbortSignal, validateBoolean, + validateFunction, validateInt32, validateInteger, validateObject, @@ -716,6 +717,15 @@ function warnOnNonPortableTemplate(template) { } } +const defaultCpOptions = { + dereference: false, + errorOnExist: false, + filter: undefined, + force: true, + preserveTimestamps: false, + recursive: false, +}; + const defaultRmOptions = { recursive: false, force: false, @@ -729,6 +739,22 @@ const defaultRmdirOptions = { recursive: false, }; +const validateCpOptions = hideStackFrames((options) => { + if (options === undefined) + return { ...defaultCpOptions }; + validateObject(options, 'options'); + options = { ...defaultCpOptions, ...options }; + validateBoolean(options.dereference, 'options.dereference'); + validateBoolean(options.errorOnExist, 'options.errorOnExist'); + validateBoolean(options.force, 'options.force'); + validateBoolean(options.preserveTimestamps, 'options.preserveTimestamps'); + validateBoolean(options.recursive, 'options.recursive'); + if (options.filter !== undefined) { + validateFunction(options.filter, 'options.filter'); + } + return options; +}); + const validateRmOptions = hideStackFrames((path, options, expectDir, cb) => { options = validateRmdirOptions(options, defaultRmOptions); validateBoolean(options.force, 'options.force'); @@ -902,6 +928,7 @@ module.exports = { Stats, toUnixTimestamp, validateBufferArray, + validateCpOptions, validateOffsetLengthRead, validateOffsetLengthWrite, validatePath, |