diff options
author | bcoe <bencoe@google.com> | 2021-08-08 23:48:01 +0300 |
---|---|---|
committer | Benjamin Coe <bencoe@google.com> | 2021-08-12 05:53:32 +0300 |
commit | 87d6fd7e696ee02178a8dc33a51e8e59bdc61d68 (patch) | |
tree | e9c76b5f1d9f4ba4f51528b2ef8e41b82b6e2f4f | |
parent | 4ece669c6205ec78abfdadfe78869bbb8411463e (diff) |
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>
-rw-r--r-- | LICENSE | 19 | ||||
-rw-r--r-- | doc/api/errors.md | 69 | ||||
-rw-r--r-- | doc/api/fs.md | 91 | ||||
-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 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/README.md | 1 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/b/README2.md | 1 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/b/index.js | 3 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/c/README2.md | 1 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/c/d/README3.md | 1 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/c/d/index.js | 3 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/c/index.js | 3 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/a/index.js | 3 | ||||
-rw-r--r-- | test/fixtures/copy/kitchen-sink/index.js | 3 | ||||
-rw-r--r-- | test/parallel/test-fs-cp.mjs | 763 | ||||
-rwxr-xr-x | tools/license-builder.sh | 3 |
20 files changed, 1782 insertions, 1 deletions
@@ -1584,3 +1584,22 @@ The externally maintained libraries used by Node.js are: OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + +- node-fs-extra, located at lib/internal/fs/cp, is licensed as follows: + """ + (The MIT License) + + Copyright (c) 2011-2017 JP Richardson + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files + (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + """ diff --git a/doc/api/errors.md b/doc/api/errors.md index 9b1a556d2b0..67dc1781d0b 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -1115,6 +1115,74 @@ added: v14.0.0 Used when a feature that is not available to the current platform which is running Node.js is used. +<a id="ERR_FS_CP_DIR_TO_NON_DIR"></a> +### `ERR_FS_CP_DIR_TO_NON_DIR` +<!-- +added: REPLACEME +--> + +An attempt was made to copy a directory to a non-directory (file, symlink, +etc.) using [`fs.cp()`][]. + +<a id="ERR_FS_CP_EEXIST"></a> +### `ERR_FS_CP_EEXIST` +<!-- +added: REPLACEME +--> + +An attempt was made to copy over a file that already existed with +[`fs.cp()`][], with the `force` and `errorOnExist` set to `true`. + +<a id="ERR_FS_CP_EINVAL"></a> +### `ERR_FS_CP_EINVAL` +<!-- +added: REPLACEME +--> + +When using [`fs.cp()`][], `src` or `dest` pointed to an invalid path. + +<a id="ERR_FS_CP_FIFO_PIPE"></a> +### `ERR_FS_CP_FIFO_PIPE` +<!-- +added: REPLACEME +--> + +An attempt was made to copy a named pipe with [`fs.cp()`][]. + +<a id="ERR_FS_CP_NON_DIR_TO_DIR"></a> +### `ERR_FS_CP_NON_DIR_TO_DIR` +<!-- +added: REPLACEME +--> + +An attempt was made to copy a non-directory (file, symlink, etc.) to a directory +using [`fs.cp()`][]. + +<a id="ERR_FS_CP_SOCKET"></a> +### `ERR_FS_CP_SOCKET` +<!-- +added: REPLACEME +--> + +An attempt was made to copy to a socket with [`fs.cp()`][]. + +<a id="ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY"></a> +### `ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY` +<!-- +added: REPLACEME +--> + +When using [`fs.cp()`][], a symlink in `dest` pointed to a subdirectory +of `src`. + +<a id="ERR_FS_CP_UNKNOWN"></a> +### `ERR_FS_CP_UNKNOWN` +<!-- +added: REPLACEME +--> + +An attempt was made to copy to an unknown file type with [`fs.cp()`][]. + <a id="ERR_FS_EISDIR"></a> ### `ERR_FS_EISDIR` @@ -2822,6 +2890,7 @@ The native call from `process.cpuUsage` could not be processed. [`dgram.remoteAddress()`]: dgram.md#dgram_socket_remoteaddress [`errno`(3) man page]: https://man7.org/linux/man-pages/man3/errno.3.html [`fs.Dir`]: fs.md#fs_class_fs_dir +[`fs.cp()`]: fs.md#fs_fs_cp_src_dest_options_callback [`fs.readFileSync`]: fs.md#fs_fs_readfilesync_path_options [`fs.readdir`]: fs.md#fs_fs_readdir_path_options_callback [`fs.symlink()`]: fs.md#fs_fs_symlink_target_path_type_callback diff --git a/doc/api/fs.md b/doc/api/fs.md index f17f0559753..a98a937c411 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -739,6 +739,37 @@ try { } ``` +### `fsPromises.cp(src, dest[, options])` +<!-- YAML +added: REPLACEME +--> + +> Stability: 1 - Experimental + +* `src` {string|URL} source path to copy. +* `dest` {string|URL} destination path to copy to. +* `options` {Object} + * `dereference` {boolean} dereference symlinks. **Default:** `false`. + * `errorOnExist` {boolean} when `force` is `false`, and the destination + exists, throw an error. **Default:** `false`. + * `filter` {Function} Function to filter copied files/directories. Return + `true` to copy the item, `false` to ignore it. Can also return a `Promise` + that resolves to `true` or `false` **Default:** `undefined`. + * `force` {boolean} overwrite existing file or directory. _The copy + operation will ignore errors if you set this to false and the destination + exists. Use the `errorOnExist` option to change this behavior. + **Default:** `true`. + * `preserveTimestamps` {boolean} When `true` timestamps from `src` will + be preserved. **Default:** `false`. + * `recursive` {boolean} copy directories recursively **Default:** `false` +* Returns: {Promise} Fulfills with `undefined` upon success. + +Asynchronously copies the entire directory structure from `src` to `dest`, +including subdirectories and files. + +When copying a directory to another directory, globs are not supported and +behavior is similar to `cp dir1/ dir2/`. + ### `fsPromises.lchmod(path, mode)` <!-- YAML deprecated: v10.0.0 @@ -1848,6 +1879,37 @@ copyFile('source.txt', 'destination.txt', callback); copyFile('source.txt', 'destination.txt', constants.COPYFILE_EXCL, callback); ``` +### `fs.cp(src, dest[, options], callback)` +<!-- YAML +added: REPLACEME +--> + +> Stability: 1 - Experimental + +* `src` {string|URL} source path to copy. +* `dest` {string|URL} destination path to copy to. +* `options` {Object} + * `dereference` {boolean} dereference symlinks. **Default:** `false`. + * `errorOnExist` {boolean} when `force` is `false`, and the destination + exists, throw an error. **Default:** `false`. + * `filter` {Function} Function to filter copied files/directories. Return + `true` to copy the item, `false` to ignore it. Can also return a `Promise` + that resolves to `true` or `false` **Default:** `undefined`. + * `force` {boolean} overwrite existing file or directory. _The copy + operation will ignore errors if you set this to false and the destination + exists. Use the `errorOnExist` option to change this behavior. + **Default:** `true`. + * `preserveTimestamps` {boolean} When `true` timestamps from `src` will + be preserved. **Default:** `false`. + * `recursive` {boolean} copy directories recursively **Default:** `false` +* `callback` {Function} + +Asynchronously copies the entire directory structure from `src` to `dest`, +including subdirectories and files. + +When copying a directory to another directory, globs are not supported and +behavior is similar to `cp dir1/ dir2/`. + ### `fs.createReadStream(path[, options])` <!-- YAML added: v0.1.31 @@ -4321,6 +4383,35 @@ console.log('source.txt was copied to destination.txt'); copyFileSync('source.txt', 'destination.txt', constants.COPYFILE_EXCL); ``` +### `fs.cpSync(src, dest[, options])` +<!-- YAML +added: REPLACEME +--> + +> Stability: 1 - Experimental + +* `src` {string|URL} source path to copy. +* `dest` {string|URL} destination path to copy to. +* `options` {Object} + * `dereference` {boolean} dereference symlinks. **Default:** `false`. + * `errorOnExist` {boolean} when `force` is `false`, and the destination + exists, throw an error. **Default:** `false`. + * `filter` {Function} Function to filter copied files/directories. Return + `true` to copy the item, `false` to ignore it. **Default:** `undefined` + * `force` {boolean} overwrite existing file or directory. _The copy + operation will ignore errors if you set this to false and the destination + exists. Use the `errorOnExist` option to change this behavior. + **Default:** `true`. + * `preserveTimestamps` {boolean} When `true` timestamps from `src` will + be preserved. **Default:** `false`. + * `recursive` {boolean} copy directories recursively **Default:** `false` + +Synchronously copies the entire directory structure from `src` to `dest`, +including subdirectories and files. + +When copying a directory to another directory, globs are not supported and +behavior is similar to `cp dir1/ dir2/`. + ### `fs.existsSync(path)` <!-- YAML added: v0.1.21 diff --git a/lib/fs.js b/lib/fs.js index cf3c885b31c..7e126b84ade 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -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 25b847b140a..7541d7d1eb8 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 00000000000..ca2102aeb5a --- /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 00000000000..9ee661be68d --- /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 692d6b42fa5..e26f1314d15 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 322b1519ef2..b0aeb8185e9 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, diff --git a/test/fixtures/copy/kitchen-sink/README.md b/test/fixtures/copy/kitchen-sink/README.md new file mode 100644 index 00000000000..fec56017dc1 --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/README.md @@ -0,0 +1 @@ +# Hello diff --git a/test/fixtures/copy/kitchen-sink/a/b/README2.md b/test/fixtures/copy/kitchen-sink/a/b/README2.md new file mode 100644 index 00000000000..fec56017dc1 --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/b/README2.md @@ -0,0 +1 @@ +# Hello diff --git a/test/fixtures/copy/kitchen-sink/a/b/index.js b/test/fixtures/copy/kitchen-sink/a/b/index.js new file mode 100644 index 00000000000..12388b0457b --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/b/index.js @@ -0,0 +1,3 @@ +module.exports = { + purpose: 'testing copy' +}; diff --git a/test/fixtures/copy/kitchen-sink/a/c/README2.md b/test/fixtures/copy/kitchen-sink/a/c/README2.md new file mode 100644 index 00000000000..fec56017dc1 --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/c/README2.md @@ -0,0 +1 @@ +# Hello diff --git a/test/fixtures/copy/kitchen-sink/a/c/d/README3.md b/test/fixtures/copy/kitchen-sink/a/c/d/README3.md new file mode 100644 index 00000000000..fec56017dc1 --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/c/d/README3.md @@ -0,0 +1 @@ +# Hello diff --git a/test/fixtures/copy/kitchen-sink/a/c/d/index.js b/test/fixtures/copy/kitchen-sink/a/c/d/index.js new file mode 100644 index 00000000000..12388b0457b --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/c/d/index.js @@ -0,0 +1,3 @@ +module.exports = { + purpose: 'testing copy' +}; diff --git a/test/fixtures/copy/kitchen-sink/a/c/index.js b/test/fixtures/copy/kitchen-sink/a/c/index.js new file mode 100644 index 00000000000..12388b0457b --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/c/index.js @@ -0,0 +1,3 @@ +module.exports = { + purpose: 'testing copy' +}; diff --git a/test/fixtures/copy/kitchen-sink/a/index.js b/test/fixtures/copy/kitchen-sink/a/index.js new file mode 100644 index 00000000000..12388b0457b --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/a/index.js @@ -0,0 +1,3 @@ +module.exports = { + purpose: 'testing copy' +}; diff --git a/test/fixtures/copy/kitchen-sink/index.js b/test/fixtures/copy/kitchen-sink/index.js new file mode 100644 index 00000000000..12388b0457b --- /dev/null +++ b/test/fixtures/copy/kitchen-sink/index.js @@ -0,0 +1,3 @@ +module.exports = { + purpose: 'testing copy' +}; diff --git a/test/parallel/test-fs-cp.mjs b/test/parallel/test-fs-cp.mjs new file mode 100644 index 00000000000..804b5a1f4c3 --- /dev/null +++ b/test/parallel/test-fs-cp.mjs @@ -0,0 +1,763 @@ +import { mustCall } from '../common/index.mjs'; + +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'; +tmpdir.refresh(); + +let dirc = 0; +function nextdir() { + return join(tmpdir.path, `copy_${++dirc}`); +} + +// Synchronous implementation of copy. + +// It copies a nested folder structure with files and folders. +{ + const src = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cpSync(src, dest, { recursive: true }); + 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'), { recursive: true }); + writeFileSync(join(src, 'README.md'), 'hello world', 'utf8'); + const dest = nextdir(); + cpSync(src, dest, { recursive: true }); + const initialStat = lstatSync(join(dest, 'README.md')); + cpSync(src, dest, { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(dest, 'README.md'), '# Goodbye', 'utf8'); + cpSync(src, dest, { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + const destFile = join(dest, 'a/b/README2.md'); + cpSync(src, dest, { dereference: true, recursive: true }); + cpSync(src, dest, { 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, { recursive: true }); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync(join(src, 'foo.js'), join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + const destFile = join(dest, 'foo.js'); + + cpSync(join(src, 'bar.js'), destFile, { dereference: true, recursive: true }); + const stat = lstatSync(destFile); + assert(stat.isFile()); +} + + +// It throws error when src and dest are identical. +{ + const src = './test/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, { recursive: true }); + const dest = nextdir(); + mkdirSync(dest); + symlinkSync(dest, join(src, 'link')); + cpSync(src, dest, { recursive: true }); + assert.throws( + () => cpSync(src, dest, { 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'), { recursive: true }); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), { recursive: true }); + symlinkSync(src, join(dest, 'a', 'c')); + assert.throws( + () => cpSync(src, dest, { recursive: true }), + { code: 'ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY' } + ); +} + +// It throws error if parent directory of symlink in dest points to src. +{ + const src = nextdir(); + mkdirSync(join(src, 'a'), { recursive: true }); + const dest = nextdir(); + // Create symlink in dest pointing to src. + const destLink = join(dest, 'b'); + mkdirSync(dest, { 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. +{ + const src = nextdir(); + mkdirSync(src, { recursive: true }); + const dest = './test/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 = './test/fixtures/copy/kitchen-sink/index.js'; + const destFile = join(nextdir(), 'index.js'); + cpSync(srcFile, destFile, { dereference: true }); + const stat = lstatSync(destFile); + assert(stat.isFile()); +} + +// It throws error if directory copied without recursive flag. +{ + const src = './test/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. +{ + const src = './test/fixtures/copy/kitchen-sink/README.md'; + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + assert.throws( + () => cpSync(src, dest), + { code: 'ERR_FS_CP_NON_DIR_TO_DIR' } + ); +} + +// It throws error if attempt is made to copy to subdirectory of self. +{ + const src = './test/fixtures/copy/kitchen-sink'; + const dest = './test/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) { + const dest = nextdir(); + const sock = `${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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cpSync(src, dest, { 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 = './test/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 = './test/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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cpSync(src, dest, { 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'), { recursive: true }); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), { recursive: true }); + writeFileSync(join(dest, 'a', 'c'), 'hello', 'utf8'); + assert.throws( + () => cpSync(src, dest, { recursive: true }), + { code: 'EEXIST' } + ); +} + +// It makes file writeable when updating timestamp, if not writeable. +{ + const src = nextdir(); + mkdirSync(src, { recursive: true }); + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(src, 'foo.txt'), 'foo', { mode: 0o444 }); + cpSync(src, dest, { 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'), { recursive: true }); + symlinkSync(src, join(src, 'a', 'c')); + const dest = nextdir(); + mkdirSync(join(dest, 'a'), { recursive: true }); + symlinkSync(dest, join(dest, 'a', 'c')); + cpSync(src, dest, { recursive: true }); + const link = readlinkSync(join(dest, 'a', 'c')); + assert.strictEqual(link, src); +} + +// It accepts file URL as src and dest. +{ + const src = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cpSync(pathToFileURL(src), pathToFileURL(dest), { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cp(src, dest, { recursive: true }, mustCall((err) => { + assert.strictEqual(err, null); + 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'), { recursive: true }); + writeFileSync(join(src, 'README.md'), 'hello world', 'utf8'); + const dest = nextdir(); + cpSync(src, dest, { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(dest, 'README.md'), '# Goodbye', 'utf8'); + + cp(src, dest, { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + const destFile = join(dest, 'a/b/README2.md'); + cpSync(src, dest, { 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, { recursive: true }); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync(join(src, 'foo.js'), join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + const destFile = join(dest, 'foo.js'); + + cp(join(src, 'bar.js'), destFile, { 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 = './test/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, { recursive: true }); + const dest = nextdir(); + mkdirSync(dest); + symlinkSync(dest, join(src, 'link')); + cpSync(src, dest, { recursive: true }); + cp(src, dest, { 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'), { recursive: true }); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), { recursive: true }); + symlinkSync(src, join(dest, 'a', 'c')); + cp(src, dest, { 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'), { recursive: true }); + const dest = nextdir(); + // Create symlink in dest pointing to src. + const destLink = join(dest, 'b'); + mkdirSync(dest, { 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, { recursive: true }); + const dest = './test/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 = './test/fixtures/copy/kitchen-sink/README.md'; + const destFile = join(nextdir(), 'index.js'); + cp(srcFile, destFile, { 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 = './test/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 = './test/fixtures/copy/kitchen-sink/README.md'; + const dest = nextdir(); + mkdirSync(dest, { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = './test/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) { + const dest = nextdir(); + const sock = `${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 = './test/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 = './test/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 = './test/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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cpSync(src, dest, { 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'), { recursive: true }); + symlinkSync(join(src, 'a', 'b'), join(src, 'a', 'c')); + + const dest = nextdir(); + mkdirSync(join(dest, 'a'), { recursive: true }); + writeFileSync(join(dest, 'a', 'c'), 'hello', 'utf8'); + cp(src, dest, { recursive: true }, mustCall((err) => { + assert.strictEqual(err.code, 'EEXIST'); + })); +} + +// It makes file writeable when updating timestamp, if not writeable. +{ + const src = nextdir(); + mkdirSync(src, { recursive: true }); + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + writeFileSync(join(src, 'foo.txt'), 'foo', { 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'), { recursive: true }); + symlinkSync(src, join(src, 'a', 'c')); + const dest = nextdir(); + mkdirSync(join(dest, 'a'), { recursive: true }); + symlinkSync(dest, join(dest, 'a', 'c')); + cp(src, dest, { 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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + cp(pathToFileURL(src), pathToFileURL(dest), { recursive: true }, + mustCall((err) => { + assert.strictEqual(err, null); + assertDirEquivalent(src, dest); + })); +} + +// It throws if options is not object. +{ + assert.throws( + () => cp('a', 'b', 'hello', () => {}), + { code: 'ERR_INVALID_ARG_TYPE' } + ); +} + +// Promises implementation of copy. + +// It copies a nested folder structure with files and folders. +{ + const src = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + const p = await fs.promises.cp(src, dest, { recursive: true }); + assert.strictEqual(p, undefined); + assertDirEquivalent(src, dest); +} + +// It accepts file URL as src and dest. +{ + const src = './test/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 = './test/fixtures/copy/kitchen-sink'; + const dest = nextdir(); + await fs.promises.cp(src, dest, { 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' } + ); +} + +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, { withFileTypes: true }); + for (const entry of newEntries) { + if (entry.isDirectory()) { + collectEntries(join(dir, entry.name), dirEntries); + } + } + dirEntries.push(...newEntries); +} diff --git a/tools/license-builder.sh b/tools/license-builder.sh index 3d8e615a0e6..6a3a6984aef 100755 --- a/tools/license-builder.sh +++ b/tools/license-builder.sh @@ -112,4 +112,7 @@ addlicense "uvwasi" "deps/uvwasi" "$(cat "${rootdir}"/deps/uvwasi/LICENSE)" addlicense "ngtcp2" "deps/ngtcp2/ngtcp2/" "$(cat "${rootdir}"/deps/ngtcp2/LICENSE_ngtcp2)" addlicense "nghttp3" "deps/ngtcp2/nghttp3/" "$(cat "${rootdir}"/deps/ngtcp2/LICENSE_nghttp3)" +addlicense "node-fs-extra" "lib/internal/fs/cp" \ + "$(curl -sL https://raw.githubusercontent.com/jprichardson/node-fs-extra/b34da2762a4865b025cac06d02d6a2f1f1027b65/LICENSE)" + mv "$tmplicense" "$licensefile" |