diff --git a/README.md b/README.md index a180c91..bc71a11 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ polyfills, and extensions, of the core `fs` module. - `fs.mkdtemp` extended to accept an `owner` option - `fs.writeFile` extended to accept an `owner` option - `fs.withTempDir` added +- `fs.cp` polyfill for node < 16.7.0 ## The `owner` option diff --git a/lib/cp/LICENSE b/lib/cp/LICENSE new file mode 100644 index 0000000..93546df --- /dev/null +++ b/lib/cp/LICENSE @@ -0,0 +1,15 @@ +(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/lib/cp/index.js b/lib/cp/index.js new file mode 100644 index 0000000..5da4739 --- /dev/null +++ b/lib/cp/index.js @@ -0,0 +1,22 @@ +const fs = require('../fs.js') +const getOptions = require('../common/get-options.js') +const node = require('../common/node.js') +const polyfill = require('./polyfill.js') + +// node 16.7.0 added fs.cp +const useNative = node.satisfies('>=16.7.0') + +const cp = async (src, dest, opts) => { + const options = getOptions(opts, { + copy: ['dereference', 'errorOnExist', 'filter', 'force', 'preserveTimestamps', 'recursive'], + }) + + // the polyfill is tested separately from this module, no need to hack + // process.version to try to trigger it just for coverage + // istanbul ignore next + return useNative + ? fs.cp(src, dest, options) + : polyfill(src, dest, options) +} + +module.exports = cp diff --git a/lib/cp/polyfill.js b/lib/cp/polyfill.js new file mode 100644 index 0000000..f83ccbf --- /dev/null +++ b/lib/cp/polyfill.js @@ -0,0 +1,428 @@ +// this file is a modified version of the code in node 17.2.0 +// which is, in turn, a modified version of the fs-extra module on npm +// node core changes: +// - Use of the assert module has been replaced with core's error system. +// - All code related to the glob dependency has been removed. +// - Bring your own custom fs module is not currently supported. +// - Some basic code cleanup. +// changes here: +// - remove all callback related code +// - drop sync support +// - change assertions back to non-internal methods (see options.js) +// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows +'use strict' + +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_ARG_TYPE, +} = require('../errors.js') +const { + constants: { + errno: { + EEXIST, + EISDIR, + EINVAL, + ENOTDIR, + }, + }, +} = require('os') +const { + chmod, + copyFile, + lstat, + mkdir, + readdir, + readlink, + stat, + symlink, + unlink, + utimes, +} = require('../fs.js') +const { + dirname, + isAbsolute, + join, + parse, + resolve, + sep, + toNamespacedPath, +} = require('path') +const { fileURLToPath } = require('url') + +const defaultOptions = { + dereference: false, + errorOnExist: false, + filter: undefined, + force: true, + preserveTimestamps: false, + recursive: false, +} + +async function cp (src, dest, opts) { + if (opts != null && typeof opts !== 'object') { + throw new ERR_INVALID_ARG_TYPE('options', ['Object'], opts) + } + return cpFn( + toNamespacedPath(getValidatedPath(src)), + toNamespacedPath(getValidatedPath(dest)), + { ...defaultOptions, ...opts }) +} + +function getValidatedPath (fileURLOrPath) { + const path = fileURLOrPath != null && fileURLOrPath.href + && fileURLOrPath.origin + ? fileURLToPath(fileURLOrPath) + : fileURLOrPath + return path +} + +async function cpFn (src, dest, opts) { + // Warn about using preserveTimestamps on 32-bit node + // istanbul ignore next + 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 Promise.all([ + statFunc(src), + statFunc(dest).catch((err) => { + // istanbul ignore next: unsure how to cover. + if (err.code === 'ENOENT') { + return null + } + // istanbul ignore next: unsure how to cover. + 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 stat(dest).then( + () => true, + // istanbul ignore next: not sure when this would occur + (err) => (err.code === 'ENOENT' ? false : Promise.reject(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) { + // istanbul ignore else: not sure when this would occur + if (err.code === 'ENOENT') { + return + } + // istanbul ignore next: not sure when this would occur + 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) => + resolve(path).split(sep).filter(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 srcArr.every((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) + // istanbul ignore else: can't portably test FIFO + 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, + }) + } + // istanbul ignore next: should be unreachable + 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. + // istanbul ignore next: can only test on windows + if (err.code === 'EINVAL' || err.code === 'UNKNOWN') { + return symlink(resolvedSrc, dest) + } + // istanbul ignore next: should not be possible + 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 = cp diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..1cd1e05 --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,129 @@ +'use strict' +const { inspect } = require('util') + +// adapted from node's internal/errors +// https://github.com/nodejs/node/blob/c8a04049/lib/internal/errors.js + +// close copy of node's internal SystemError class. +class SystemError { + constructor (code, prefix, context) { + // XXX context.code is undefined in all constructors used in cp/polyfill + // that may be a bug copied from node, maybe the constructor should use + // `code` not `errno`? nodejs/node#41104 + let message = `${prefix}: ${context.syscall} returned ` + + `${context.code} (${context.message})` + + if (context.path !== undefined) { + message += ` ${context.path}` + } + if (context.dest !== undefined) { + message += ` => ${context.dest}` + } + + this.code = code + Object.defineProperties(this, { + name: { + value: 'SystemError', + enumerable: false, + writable: true, + configurable: true, + }, + message: { + value: message, + enumerable: false, + writable: true, + configurable: true, + }, + info: { + value: context, + enumerable: true, + configurable: true, + writable: false, + }, + errno: { + get () { + return context.errno + }, + set (value) { + context.errno = value + }, + enumerable: true, + configurable: true, + }, + syscall: { + get () { + return context.syscall + }, + set (value) { + context.syscall = value + }, + enumerable: true, + configurable: true, + }, + }) + + if (context.path !== undefined) { + Object.defineProperty(this, 'path', { + get () { + return context.path + }, + set (value) { + context.path = value + }, + enumerable: true, + configurable: true, + }) + } + + if (context.dest !== undefined) { + Object.defineProperty(this, 'dest', { + get () { + return context.dest + }, + set (value) { + context.dest = value + }, + enumerable: true, + configurable: true, + }) + } + } + + toString () { + return `${this.name} [${this.code}]: ${this.message}` + } + + [Symbol.for('nodejs.util.inspect.custom')] (_recurseTimes, ctx) { + return inspect(this, { + ...ctx, + getters: true, + customInspect: false, + }) + } +} + +function E (code, message) { + module.exports[code] = class NodeError extends SystemError { + constructor (ctx) { + super(code, message, ctx) + } + } +} + +E('ERR_FS_CP_DIR_TO_NON_DIR', 'Cannot overwrite directory with non-directory') +E('ERR_FS_CP_EEXIST', 'Target already exists') +E('ERR_FS_CP_EINVAL', 'Invalid src or dest') +E('ERR_FS_CP_FIFO_PIPE', 'Cannot copy a FIFO pipe') +E('ERR_FS_CP_NON_DIR_TO_DIR', 'Cannot overwrite non-directory with directory') +E('ERR_FS_CP_SOCKET', 'Cannot copy a socket file') +E('ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY', 'Cannot overwrite symlink in subdirectory of self') +E('ERR_FS_CP_UNKNOWN', 'Cannot copy an unknown file type') +E('ERR_FS_EISDIR', 'Path is a directory') + +module.exports.ERR_INVALID_ARG_TYPE = class ERR_INVALID_ARG_TYPE extends Error { + constructor (name, expected, actual) { + super() + this.code = 'ERR_INVALID_ARG_TYPE' + this.message = `The ${name} argument must be ${expected}. Received ${typeof actual}` + } +} diff --git a/lib/index.js b/lib/index.js index f669efc..e40d748 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,7 @@ module.exports = { ...require('./fs.js'), copyFile: require('./copy-file.js'), + cp: require('./cp/index.js'), mkdir: require('./mkdir/index.js'), mkdtemp: require('./mkdtemp.js'), rm: require('./rm/index.js'), diff --git a/test/cp/index.js b/test/cp/index.js new file mode 100644 index 0000000..5ff75fd --- /dev/null +++ b/test/cp/index.js @@ -0,0 +1,28 @@ +const { join } = require('path') +const t = require('tap') + +const fs = require('../../') + +t.test('can copy a file', async (t) => { + const dir = t.testdir({ + file: 'some random file', + }) + const src = join(dir, 'file') + const dest = join(dir, 'dest') + + await fs.cp(src, dest) + + t.equal(await fs.exists(dest), true, 'dest exits') +}) + +t.test('can copy a directory', async (t) => { + const dir = t.testdir({ + directory: {}, + }) + const src = join(dir, 'directory') + const dest = join(dir, 'dest') + + await fs.cp(src, dest, { recursive: true }) + + t.equal(await fs.exists(dest), true, 'dest exists') +}) diff --git a/test/cp/polyfill.js b/test/cp/polyfill.js new file mode 100644 index 0000000..a75e80d --- /dev/null +++ b/test/cp/polyfill.js @@ -0,0 +1,380 @@ +const fs = require('fs') +const { + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + symlinkSync, + statSync, + writeFileSync, +} = fs + +const net = require('net') +const { join } = require('path') +const { pathToFileURL } = require('url') +const t = require('tap') + +const cp = require('../../lib/cp/polyfill') + +const isWindows = process.platform === 'win32' +const tmpdir = t.testdir({ + 'kitchen-sink': { + a: { + b: { + 'index.js': 'module.exports = { purpose: "testing copy" };', + 'README2.md': '# Hello', + }, + c: { + d: { + 'index.js': 'module.exports = { purpose: "testing copy" };', + 'README3.md': '# Hello', + }, + }, + 'index.js': 'module.exports = { purpose: "testing copy" };', + 'README2.md': '# Hello', + }, + 'index.js': 'module.exports = { purpose: "testing copy" };', + 'README.md': '# Hello', + }, +}) +const kitchenSink = join(tmpdir, 'kitchen-sink') + +let dirc = 0 +function nextdir () { + return join(tmpdir, `copy_${++dirc}`) +} + +t.test('It copies a nested folder structure with files and folders.', async t => { + const src = kitchenSink + const dest = nextdir() + await cp(src, dest, { recursive: true }) + assertDirEquivalent(t, src, dest) +}) + +t.test('It does not throw errors when directory is copied over and force is false.', async t => { + const src = nextdir() + mkdirSync(join(src, 'a', 'b'), { recursive: true }) + writeFileSync(join(src, 'README.md'), 'hello world', 'utf8') + const dest = nextdir() + await cp(src, dest, { dereference: true, recursive: true }) + const initialStat = lstatSync(join(dest, 'README.md')) + await cp(src, dest, { + dereference: true, + force: false, + recursive: true, + }) + + // File should not have been copied over, so access times will be identical: + const finalStat = lstatSync(join(dest, 'README.md')) + t.equal(finalStat.ctime.getTime(), initialStat.ctime.getTime()) +}) + +t.test('It overwrites existing files if force is true.', async t => { + const src = kitchenSink + const dest = nextdir() + mkdirSync(dest, { recursive: true }) + writeFileSync(join(dest, 'README.md'), '# Goodbye', 'utf8') + + await cp(src, dest, { recursive: true }) + assertDirEquivalent(t, src, dest) + const content = readFileSync(join(dest, 'README.md'), 'utf8') + t.equal(content.trim(), '# Hello') +}) + +t.test('It can overwrite directory when dereference is true and force is false', async t => { + const src = kitchenSink + const dest = nextdir() + const destFile = join(dest, 'a/b/README2.md') + await cp(src, dest, { dereference: true, recursive: true }) + + await cp(src, dest, { + dereference: true, + recursive: true, + }) + const stat = lstatSync(destFile) + t.ok(stat.isFile()) +}) + +t.test('It copies file itself, rather than symlink, when dereference is true.', async t => { + 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') + + await cp(join(src, 'bar.js'), destFile, { dereference: true }) + const stat = lstatSync(destFile) + t.ok(stat.isFile()) +}) + +t.test('It copies relative symlinks', async t => { + const src = nextdir() + mkdirSync(src, { recursive: true }) + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8') + symlinkSync('./foo.js', join(src, 'bar.js')) + + const dest = nextdir() + const destFile = join(dest, 'bar.js') + mkdirSync(dest, { recursive: true }) + writeFileSync(join(dest, 'foo.js'), 'foo', 'utf8') + symlinkSync('./foo.js', destFile) + + await cp(src, dest, { recursive: true }) + const stat = lstatSync(destFile) + t.ok(stat.isSymbolicLink()) +}) + +t.test('It returns error when src and dest are identical.', async t => { + t.rejects( + cp(kitchenSink, kitchenSink), + { code: 'ERR_FS_CP_EINVAL' }) +}) + +t.test('It returns error if symlink in src points to location in dest.', async t => { + const src = nextdir() + mkdirSync(src, { recursive: true }) + const dest = nextdir() + mkdirSync(dest) + symlinkSync(dest, join(src, 'link')) + await cp(src, dest, { recursive: true }) + t.rejects( + cp(src, dest, { recursive: true }), + { code: 'ERR_FS_CP_EINVAL' }) +}) + +t.test('It returns error if symlink in dest points to location in src.', async t => { + 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')) + t.rejects( + cp(src, dest, { recursive: true }), + { code: 'ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY' }) +}) + +t.test('It returns error if parent directory of symlink in dest points to src.', async t => { + 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) + t.rejects( + cp(src, join(dest, 'b', 'c')), + { code: 'ERR_FS_CP_EINVAL' }) +}) + +t.test('It returns error if attempt is made to copy directory to file.', async t => { + const src = nextdir() + mkdirSync(src, { recursive: true }) + const dest = join(kitchenSink, 'README.md') + t.rejects( + cp(src, dest), + { code: 'ERR_FS_CP_DIR_TO_NON_DIR' }) +}) + +t.test('It allows file to be copied to a file path.', async t => { + const srcFile = join(kitchenSink, 'README.md') + const destFile = join(nextdir(), 'index.js') + await cp(srcFile, destFile, { dereference: true }) + const stat = lstatSync(destFile) + t.ok(stat.isFile()) +}) + +t.test('It returns error if directory copied without recursive flag.', async t => { + const src = kitchenSink + const dest = nextdir() + t.rejects( + cp(src, dest), + { code: 'ERR_FS_EISDIR' }) +}) + +t.test('It returns error if attempt is made to copy file to directory.', async t => { + const src = join(kitchenSink, 'README.md') + const dest = nextdir() + mkdirSync(dest, { recursive: true }) + t.rejects( + cp(src, dest), + { code: 'ERR_FS_CP_NON_DIR_TO_DIR' }) +}) + +t.test('It returns error if attempt is made to copy to subdirectory of self.', async t => { + const src = kitchenSink + const dest = join(kitchenSink, 'a') + t.rejects( + cp(src, dest), + { code: 'ERR_FS_CP_EINVAL' }) +}) + +t.test('It returns an error if attempt is made to copy socket.', { skip: isWindows }, async t => { + const dest = nextdir() + const sock = `${process.pid}.sock` + const server = net.createServer() + server.listen(sock) + t.teardown(() => server.close()) + t.rejects( + cp(sock, dest), + { code: 'ERR_FS_CP_SOCKET' }) +}) + +t.test('It copies timestamps from src to dest if preserveTimestamps is true.', async t => { + const src = kitchenSink + const dest = nextdir() + await cp(src, dest, { + preserveTimestamps: true, + recursive: true, + }) + assertDirEquivalent(t, src, dest) + const srcStat = lstatSync(join(src, 'index.js')) + const destStat = lstatSync(join(dest, 'index.js')) + t.equal(srcStat.mtime.getTime(), destStat.mtime.getTime()) +}) + +t.test('It applies filter function.', async t => { + const src = kitchenSink + const dest = nextdir() + await cp(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) { + t.equal( + entry.isDirectory() || entry.name.endsWith('.js'), + true + ) + } +}) + +t.test('It supports async filter function.', async t => { + const src = kitchenSink + const dest = nextdir() + await cp(src, dest, { + filter: async (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) { + t.equal( + entry.isDirectory() || entry.name.endsWith('.js'), + true + ) + } +}) + +t.test('It errors on overwrite if force is false and errorOnExist is true', async t => { + const src = kitchenSink + const dest = nextdir() + await cp(src, dest, { recursive: true }) + t.rejects( + cp(src, dest, { + dereference: true, + errorOnExist: true, + force: false, + recursive: true, + }), + { code: 'ERR_FS_CP_EEXIST' }) +}) + +t.test('It returns EEXIST error if attempt is made to copy symlink over file.', async t => { + 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') + t.rejects( + cp(src, dest, { recursive: true }), + { code: 'EEXIST' }) +}) + +t.test('It makes file writeable when updating timestamp, if not writeable.', async t => { + const src = nextdir() + mkdirSync(src, { recursive: true }) + const dest = nextdir() + mkdirSync(dest, { recursive: true }) + writeFileSync(join(src, 'foo.txt'), 'foo', { mode: 0o444 }) + await cp(src, dest, { + preserveTimestamps: true, + recursive: true, + }) + assertDirEquivalent(t, src, dest) + const srcStat = lstatSync(join(src, 'foo.txt')) + const destStat = lstatSync(join(dest, 'foo.txt')) + t.equal(srcStat.mtime.getTime(), destStat.mtime.getTime()) +}) + +t.test('It copies link if it does not point to folder in src.', async t => { + 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')) + await cp(src, dest, { recursive: true }) + const link = readlinkSync(join(dest, 'a', 'c')) + t.equal(link, src) +}) + +t.test('It accepts file URL as src and dest.', async t => { + const src = kitchenSink + const dest = nextdir() + await cp(pathToFileURL(src), pathToFileURL(dest), { recursive: true }) + assertDirEquivalent(t, src, dest) +}) + +t.test('It throws if options is not object.', async t => { + t.rejects( + () => cp('a', 'b', 'hello'), + { code: 'ERR_INVALID_ARG_TYPE' }) +}) + +function assertDirEquivalent (t, dir1, dir2) { + const dir1Entries = [] + collectEntries(dir1, dir1Entries) + const dir2Entries = [] + collectEntries(dir2, dir2Entries) + t.equal(dir1Entries.length, dir2Entries.length) + for (const entry1 of dir1Entries) { + const entry2 = dir2Entries.find((entry) => { + return entry.name === entry1.name + }) + t.ok(entry2, `entry ${entry2.name} not copied`) + if (entry1.isFile()) { + t.ok(entry2.isFile(), `${entry2.name} was not file`) + } else if (entry1.isDirectory()) { + t.ok(entry2.isDirectory(), `${entry2.name} was not directory`) + } else if (entry1.isSymbolicLink()) { + t.ok(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/test/errors.js b/test/errors.js new file mode 100644 index 0000000..12997ac --- /dev/null +++ b/test/errors.js @@ -0,0 +1,81 @@ +const t = require('tap') +const { ERR_FS_EISDIR } = require('../lib/errors') +const { constants: { errno: { EISDIR, EIO } } } = require('os') +const { inspect } = require('util') + +t.test('message with path and dest', async t => { + const err = new ERR_FS_EISDIR({ + path: 'path', + dest: 'dest', + syscall: 'cp', + code: EISDIR, + message: 'failed', + }) + + t.equal(err.message, `Path is a directory: cp returned ${EISDIR} (failed) path => dest`) +}) + +t.test('message without path or dest', async t => { + const err = new ERR_FS_EISDIR({ + syscall: 'cp', + code: EISDIR, + message: 'failed', + }) + + t.equal(err.message, `Path is a directory: cp returned ${EISDIR} (failed)`) +}) + +t.test('errno is alias for info.errno', async t => { + const err = new ERR_FS_EISDIR({ errno: EISDIR }) + t.equal(err.errno, EISDIR) + t.equal(err.info.errno, EISDIR) + err.errno = EIO + t.equal(err.errno, EIO) + t.equal(err.info.errno, EIO) +}) + +t.test('syscall is alias for info.syscall', async t => { + const err = new ERR_FS_EISDIR({ syscall: 'cp' }) + t.equal(err.syscall, 'cp') + t.equal(err.info.syscall, 'cp') + err.syscall = 'readlink' + t.equal(err.syscall, 'readlink') + t.equal(err.info.syscall, 'readlink') +}) + +t.test('path is alias for info.path', async t => { + const err = new ERR_FS_EISDIR({ path: 'first' }) + t.equal(err.path, 'first') + t.equal(err.info.path, 'first') + err.path = 'second' + t.equal(err.path, 'second') + t.equal(err.info.path, 'second') +}) + +t.test('dest is alias for info.dest', async t => { + const err = new ERR_FS_EISDIR({ dest: 'first' }) + t.equal(err.dest, 'first') + t.equal(err.info.dest, 'first') + err.dest = 'second' + t.equal(err.dest, 'second') + t.equal(err.info.dest, 'second') +}) + +t.test('toString', async t => { + const err = new ERR_FS_EISDIR({ + syscall: 'cp', + code: EISDIR, + message: 'failed', + }) + t.equal(err.toString(), + `SystemError [ERR_FS_EISDIR]: Path is a directory: cp returned ${EISDIR} (failed)`) +}) + +t.test('inspect', async t => { + const err = new ERR_FS_EISDIR({ + syscall: 'cp', + errno: EISDIR, + message: 'failed', + }) + t.ok(inspect(err)) +})