diff --git a/doc/api/fs.md b/doc/api/fs.md index 9cb84007b3b815..de03d99f927e05 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1367,8 +1367,8 @@ changes: whether to perform path resolution for symlinks. --> -* `src` {string|URL} source path to copy. -* `dest` {string|URL} destination path to copy to. +* `src` {string|Buffer|URL} source path to copy. +* `dest` {string|Buffer|URL} destination path to copy to. * `options` {Object} * `dereference` {boolean} dereference symlinks. **Default:** `false`. * `errorOnExist` {boolean} when `force` is `false`, and the destination @@ -1377,8 +1377,8 @@ changes: `true` to copy the item, `false` to ignore it. When ignoring a directory, all of its contents will be skipped as well. Can also return a `Promise` that resolves to `true` or `false` **Default:** `undefined`. - * `src` {string} source path to copy. - * `dest` {string} destination path to copy to. + * `src` {string|Buffer} source path to copy. + * `dest` {string|Buffer} destination path to copy to. * Returns: {boolean|Promise} A value that is coercible to `boolean` or a `Promise` that fulfils with such value. * `force` {boolean} overwrite existing file or directory. The copy @@ -2892,8 +2892,8 @@ changes: whether to perform path resolution for symlinks. --> -* `src` {string|URL} source path to copy. -* `dest` {string|URL} destination path to copy to. +* `src` {string|Buffer|URL} source path to copy. +* `dest` {string|Buffer|URL} destination path to copy to. * `options` {Object} * `dereference` {boolean} dereference symlinks. **Default:** `false`. * `errorOnExist` {boolean} when `force` is `false`, and the destination @@ -2902,8 +2902,8 @@ changes: `true` to copy the item, `false` to ignore it. When ignoring a directory, all of its contents will be skipped as well. Can also return a `Promise` that fulfills with `true` or `false`. **Default:** `undefined`. - * `src` {string} source path to copy. - * `dest` {string} destination path to copy to. + * `src` {string|Buffer} source path to copy. + * `dest` {string|Buffer} destination path to copy to. * Returns: {boolean|Promise} A value that is coercible to `boolean` or a `Promise` that fulfils with such value. * `force` {boolean} overwrite existing file or directory. The copy @@ -6023,8 +6023,8 @@ changes: whether to perform path resolution for symlinks. --> -* `src` {string|URL} source path to copy. -* `dest` {string|URL} destination path to copy to. +* `src` {string|Buffer|URL} source path to copy. +* `dest` {string|Buffer|URL} destination path to copy to. * `options` {Object} * `dereference` {boolean} dereference symlinks. **Default:** `false`. * `errorOnExist` {boolean} when `force` is `false`, and the destination @@ -6032,8 +6032,8 @@ changes: * `filter` {Function} Function to filter copied files/directories. Return `true` to copy the item, `false` to ignore it. When ignoring a directory, all of its contents will be skipped as well. **Default:** `undefined` - * `src` {string} source path to copy. - * `dest` {string} destination path to copy to. + * `src` {string|Buffer} source path to copy. + * `dest` {string|Buffer} destination path to copy to. * Returns: {boolean} Any non-`Promise` value that is coercible to `boolean`. * `force` {boolean} overwrite existing file or directory. The copy diff --git a/lib/fs.js b/lib/fs.js index 4fbdaf8130180a..947c5bf23c523d 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -107,6 +107,7 @@ const { getDirents, getOptions, getValidatedFd, + getValidatedCpPath, getValidatedPath, handleErrorFromBinding, preprocessSymlinkDestination, @@ -3694,8 +3695,8 @@ function cp(src, dest, options, callback) { } callback = makeCallback(callback); options = validateCpOptions(options); - src = getValidatedPath(src, 'src'); - dest = getValidatedPath(dest, 'dest'); + src = getValidatedCpPath(src, 'src'); + dest = getValidatedCpPath(dest, 'dest'); lazyLoadCp(); cpFn(src, dest, options, callback); } @@ -3710,8 +3711,8 @@ function cp(src, dest, options, callback) { */ function cpSync(src, dest, options) { options = validateCpOptions(options); - src = getValidatedPath(src, 'src'); - dest = getValidatedPath(dest, 'dest'); + src = getValidatedCpPath(src, 'src'); + dest = getValidatedCpPath(dest, 'dest'); lazyLoadCp(); cpSyncFn(src, dest, options); } diff --git a/lib/internal/fs/cp/cp-sync.js b/lib/internal/fs/cp/cp-sync.js index 03fcae9b7cdbda..ad493cfa2ca4dc 100644 --- a/lib/internal/fs/cp/cp-sync.js +++ b/lib/internal/fs/cp/cp-sync.js @@ -3,7 +3,13 @@ // This file is a modified version of the fs-extra's copySync method. const fsBinding = internalBinding('fs'); -const { isSrcSubdir } = require('internal/fs/cp/cp'); +const { + dirnamePath, + isSrcSubdir, + joinPath, + maybeDecodePath, + resolveLinkTarget, +} = require('internal/fs/cp/path'); const { codes: { ERR_FS_CP_EEXIST, ERR_FS_CP_EINVAL, @@ -30,12 +36,6 @@ const { unlinkSync, utimesSync, } = require('fs'); -const { - dirname, - isAbsolute, - join, - resolve, -} = require('path'); const { isPromise } = require('util/types'); function cpSyncFn(src, dest, opts) { @@ -138,9 +138,10 @@ function onDir(srcStat, destStat, src, dest, opts) { } function copyDir(src, dest, opts, mkDir, srcMode) { - if (!opts.filter) { - // The caller didn't provide a js filter function, in this case - // we can run the whole function faster in C++ + if (!opts.filter && typeof src === 'string' && typeof dest === 'string') { + // The caller didn't provide a js filter function and both paths are strings, + // in this case we can run the whole function faster in C++. + // Buffer paths require the byte-preserving JavaScript traversal. // TODO(dario-piotrowicz): look into making cpSyncCopyDir also accept the potential filter function return fsBinding.cpSyncCopyDir(src, dest, opts.force, @@ -154,15 +155,15 @@ function copyDir(src, dest, opts, mkDir, srcMode) { mkdirSync(dest); } - const dir = opendirSync(src); + const dir = opendirSync(src, { encoding: 'buffer' }); try { let dirent; while ((dirent = dir.readSync()) !== null) { const { name } = dirent; - const srcItem = join(src, name); - const destItem = join(dest, name); + const srcItem = joinPath(src, name); + const destItem = joinPath(dest, name); let shouldCopy = true; if (opts.filter) { @@ -187,18 +188,23 @@ function copyDir(src, dest, opts, mkDir, srcMode) { // TODO(@anonrig): Move this function to C++. function onLink(destStat, src, dest, verbatimSymlinks) { - let resolvedSrc = readlinkSync(src); - if (!verbatimSymlinks && !isAbsolute(resolvedSrc)) { - resolvedSrc = resolve(dirname(src), resolvedSrc); - } - if (!destStat) { - return symlinkSync(resolvedSrc, dest); + let resolvedSrc = maybeDecodePath( + readlinkSync(src, { encoding: 'buffer' }), + typeof src === 'string', + ); + if (!verbatimSymlinks) { + resolvedSrc = resolveLinkTarget(dirnamePath(src), resolvedSrc); } + if (!destStat) return symlinkSync(resolvedSrc, dest); + let resolvedDest; try { - resolvedDest = readlinkSync(dest); + resolvedDest = maybeDecodePath( + readlinkSync(dest, { encoding: 'buffer' }), + typeof dest === 'string', + ); } catch (err) { - // Dest exists and is a regular file or directory, + // 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') { @@ -206,24 +212,25 @@ function onLink(destStat, src, dest, verbatimSymlinks) { } throw err; } - if (!isAbsolute(resolvedDest)) { - resolvedDest = resolve(dirname(dest), resolvedDest); + if (!verbatimSymlinks) { + resolvedDest = resolveLinkTarget(dirnamePath(dest), resolvedDest); } - const srcIsDir = fsBinding.internalModuleStat(src) === 1; + const srcIsDir = typeof src === 'string' ? + fsBinding.internalModuleStat(src) === 1 : + statSync(src).isDirectory(); if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) { throw new ERR_FS_CP_EINVAL({ message: `cannot copy ${resolvedSrc} to a subdirectory of self ` + - `${resolvedDest}`, + `${resolvedDest}`, path: dest, syscall: 'cp', errno: EINVAL, code: '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. + // 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. if (statSync(dest).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) { throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({ message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`, diff --git a/lib/internal/fs/cp/cp.js b/lib/internal/fs/cp/cp.js index 10c52b11463445..e8f96c01ec0c68 100644 --- a/lib/internal/fs/cp/cp.js +++ b/lib/internal/fs/cp/cp.js @@ -3,13 +3,9 @@ // This file is a modified version of the fs-extra's copy method. const { - ArrayPrototypeEvery, - ArrayPrototypeFilter, - Boolean, PromisePrototypeThen, PromiseReject, SafePromiseAll, - StringPrototypeSplit, } = primordials; const { codes: { @@ -46,16 +42,20 @@ const { unlink, utimes, } = require('fs/promises'); -const { - dirname, - isAbsolute, - join, - parse, - resolve, - sep, -} = require('path'); const fsBinding = internalBinding('fs'); + +const { + dirnamePath, + isPathRoot, + isSrcSubdir, + joinPath, + maybeDecodePath, + pathEquals, + resolveLinkTarget, + resolvePath, +} = require('internal/fs/cp/path'); + async function cpFn(src, dest, opts) { // Warn about using preserveTimestamps on 32-bit node if (opts.preserveTimestamps && process.arch === 'ia32') { @@ -138,7 +138,7 @@ function getStats(src, dest, opts) { } async function checkParentDir(destStat, src, dest, opts) { - const destParent = dirname(dest); + const destParent = dirnamePath(dest); const dirExists = await pathExists(destParent); if (dirExists) return getStatsForCopy(destStat, src, dest, opts); await mkdir(destParent, { recursive: true }); @@ -153,15 +153,14 @@ function pathExists(dest) { } // 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. +// 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; - } + const srcParent = resolvePath(dirnamePath(src)); + const destParent = resolvePath(dirnamePath(dest)); + if (pathEquals(destParent, srcParent) || isPathRoot(destParent)) return; + let destStat; try { destStat = await stat(destParent, { bigint: true }); @@ -181,17 +180,6 @@ async function checkParentPaths(src, srcStat, dest) { 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 getStatsForCopy(destStat, src, dest, opts) { const statFn = opts.dereference ? stat : lstat; const srcStat = await statFn(src); @@ -321,29 +309,34 @@ async function mkDirAndCopy(srcMode, src, dest, opts) { } async function copyDir(src, dest, opts) { - const dir = await opendir(src); + const dir = await opendir(src, { encoding: 'buffer' }); for await (const { name } of dir) { - const srcItem = join(src, name); - const destItem = join(dest, name); + const srcItem = joinPath(src, name); + const destItem = joinPath(dest, name); const { destStat, skipped } = await checkPaths(srcItem, destItem, opts); if (!skipped) await getStatsForCopy(destStat, srcItem, destItem, opts); } } async function onLink(destStat, src, dest, opts) { - let resolvedSrc = await readlink(src); - if (!opts.verbatimSymlinks && !isAbsolute(resolvedSrc)) { - resolvedSrc = resolve(dirname(src), resolvedSrc); - } - if (!destStat) { - return symlink(resolvedSrc, dest); + let resolvedSrc = maybeDecodePath( + await readlink(src, { encoding: 'buffer' }), + typeof src === 'string', + ); + if (!opts.verbatimSymlinks) { + resolvedSrc = resolveLinkTarget(dirnamePath(src), resolvedSrc); } + if (!destStat) return symlink(resolvedSrc, dest); + let resolvedDest; try { - resolvedDest = await readlink(dest); + resolvedDest = maybeDecodePath( + await readlink(dest, { encoding: 'buffer' }), + typeof dest === 'string', + ); } catch (err) { - // Dest exists and is a regular file or directory, + // 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') { @@ -351,27 +344,28 @@ async function onLink(destStat, src, dest, opts) { } throw err; } - if (!isAbsolute(resolvedDest)) { - resolvedDest = resolve(dirname(dest), resolvedDest); + if (!opts.verbatimSymlinks) { + resolvedDest = resolveLinkTarget(dirnamePath(dest), resolvedDest); } - const srcIsDir = fsBinding.internalModuleStat(src) === 1; - + const srcStat = typeof src === 'string' ? null : await stat(src); + const srcIsDir = srcStat ? + srcStat.isDirectory() : + fsBinding.internalModuleStat(src) === 1; if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) { throw new ERR_FS_CP_EINVAL({ message: `cannot copy ${resolvedSrc} to a subdirectory of self ` + - `${resolvedDest}`, + `${resolvedDest}`, path: dest, syscall: 'cp', errno: EINVAL, code: '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)) { + // 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. + const followedSrcStat = srcStat || await stat(src); + if (followedSrcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) { throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({ message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`, path: dest, @@ -391,5 +385,4 @@ async function copyLink(resolvedSrc, dest) { module.exports = { areIdentical, cpFn, - isSrcSubdir, }; diff --git a/lib/internal/fs/cp/path.js b/lib/internal/fs/cp/path.js new file mode 100644 index 00000000000000..1fbefeff947be2 --- /dev/null +++ b/lib/internal/fs/cp/path.js @@ -0,0 +1,135 @@ +'use strict'; + +const { + ArrayPrototypeEvery, + ArrayPrototypeFilter, + ArrayPrototypeMap, + Boolean, + StringPrototypeSplit, +} = primordials; + +const { Buffer, isUtf8 } = require('buffer'); +const { isWindows } = require('internal/util'); +const { + dirname, + isAbsolute, + join, + resolve, + sep, +} = require('path'); + +// POSIX paths are opaque byte sequences. Latin-1 provides a reversible mapping +// between each byte and one JavaScript code unit while the path module handles +// separators and dot segments. Windows paths use their native UTF-8 encoding. +const pathEncoding = isWindows ? 'utf8' : 'latin1'; + +function isUtf8Path(path) { + return isUtf8(path); +} + +function toPathBuffer(path) { + if (typeof path === 'string') return Buffer.from(path); + return Buffer.isBuffer(path) ? path : Buffer.from(path); +} + +function toPathString(path) { + return typeof path === 'string' ? + path : toPathBuffer(path).toString(pathEncoding); +} + +function toBytePathString(path) { + return toPathBuffer(path).toString(pathEncoding); +} + +function fromPathString(path) { + return Buffer.from(path, pathEncoding); +} + +function pathEquals(left, right) { + if (typeof left === 'string' && typeof right === 'string') { + return left === right; + } + return toPathBuffer(left).equals(toPathBuffer(right)); +} + +function joinPath(parent, name) { + if (typeof parent === 'string' && typeof name === 'string') { + return join(parent, name); + } + + if (typeof parent === 'string' && isUtf8Path(name)) { + return join(parent, name.toString()); + } + + return fromPathString(join(toBytePathString(parent), + toBytePathString(name))); +} + +function dirnamePath(path) { + if (typeof path === 'string') return dirname(path); + return fromPathString(dirname(toPathString(path))); +} + +function isAbsolutePath(path) { + return isAbsolute(toPathString(path)); +} + +function resolvePath(path) { + if (typeof path === 'string') return resolve(path); + const pathBuffer = toPathBuffer(path); + if (!isWindows && !isAbsolutePath(pathBuffer)) { + return fromPathString(resolve(toBytePathString(Buffer.concat([ + Buffer.from(process.cwd()), + Buffer.from(sep), + pathBuffer, + ])))); + } + return fromPathString(resolve(toBytePathString(pathBuffer))); +} + +function isPathRoot(path) { + const resolved = resolvePath(path); + return pathEquals(resolved, dirnamePath(resolved)); +} + +function resolvedPathParts(path) { + const resolved = toPathBuffer(resolvePath(path)).toString('latin1'); + const parts = ArrayPrototypeFilter( + StringPrototypeSplit(resolved, sep), + Boolean, + ); + return ArrayPrototypeMap(parts, (part) => Buffer.from(part, 'latin1')); +} + +function isSrcSubdir(src, dest) { + const srcParts = resolvedPathParts(src); + const destParts = resolvedPathParts(dest); + if (srcParts.length > destParts.length) return false; + return ArrayPrototypeEvery( + srcParts, + (part, index) => part.equals(destParts[index]), + ); +} + +function maybeDecodePath(path, preferString) { + return preferString && isUtf8Path(path) ? path.toString() : path; +} + +function resolveLinkTarget(parent, target) { + if (isAbsolutePath(target)) return target; + if (typeof parent === 'string' && typeof target === 'string') { + return resolve(parent, target); + } + return resolvePath(joinPath(parent, target)); +} + +module.exports = { + dirnamePath, + isPathRoot, + isSrcSubdir, + joinPath, + maybeDecodePath, + pathEquals, + resolveLinkTarget, + resolvePath, +}; diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 3e336024a15a3c..894f754daa2b2f 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -65,6 +65,7 @@ const { getOptions, getStatFsFromBinding, getStatsFromBinding, + getValidatedCpPath, getValidatedPath, getReadFileBuffer, getReadFileBufferByteLengthName, @@ -1323,8 +1324,8 @@ async function access(path, mode = F_OK) { async function cp(src, dest, options) { options = validateCpOptions(options); - src = getValidatedPath(src, 'src'); - dest = getValidatedPath(dest, 'dest'); + src = getValidatedCpPath(src, 'src'); + dest = getValidatedCpPath(dest, 'dest'); return lazyLoadCpPromises()(src, dest, options); } diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index 70aaa7e7c58e36..d04c331ad50b34 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -53,7 +53,7 @@ const { once, setOwnProperty, } = require('internal/util'); -const { toPathIfFileURL } = require('internal/url'); +const { fileURLToPathBuffer, isURL, toPathIfFileURL } = require('internal/url'); const { validateAbortSignal, validateBoolean, @@ -859,6 +859,18 @@ const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => { return path; }); +const getValidatedCpPath = hideStackFrames((fileURLOrPath, propName = 'path') => { + if (!isURL(fileURLOrPath)) { + validatePath(fileURLOrPath, propName); + return typeof fileURLOrPath === 'string' || Buffer.isBuffer(fileURLOrPath) ? + fileURLOrPath : Buffer.from(fileURLOrPath); + } + + const pathBuffer = fileURLToPathBuffer(fileURLOrPath); + const path = pathBuffer.toString(); + return Buffer.from(path).equals(pathBuffer) ? path : pathBuffer; +}); + const getValidatedFd = hideStackFrames((fd, propName = 'fd') => { if (ObjectIs(fd, -0)) { return 0; @@ -1129,6 +1141,7 @@ module.exports = { getDirents, getOptions, getValidatedFd, + getValidatedCpPath, getValidatedPath, handleErrorFromBinding, preprocessSymlinkDestination, diff --git a/test/known_issues/test-fs-cp-async-buffer.js b/test/known_issues/test-fs-cp-async-buffer.js deleted file mode 100644 index 4cbf02414749fe..00000000000000 --- a/test/known_issues/test-fs-cp-async-buffer.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -// We expect this test to fail because the implementation of fsPromise.cp -// does not properly support the use of Buffer as the source or destination -// argument like fs.cpSync does. -// Refs: https://github.com/nodejs/node/issues/58634 -// Refs: https://github.com/nodejs/node/issues/58869 - -const common = require('../common'); -const { mkdirSync, promises } = require('fs'); -const { join } = require('path'); -const tmpdir = require('../common/tmpdir'); - -tmpdir.refresh(); - -const tmpA = join(tmpdir.path, 'a'); -const tmpB = join(tmpdir.path, 'b'); - -mkdirSync(tmpA, { recursive: true }); - -promises.cp(Buffer.from(tmpA), Buffer.from(tmpB), { - recursive: true, -}).then(common.mustCall()); diff --git a/test/known_issues/test-fs-cp-filter.js b/test/known_issues/test-fs-cp-filter.js deleted file mode 100644 index 334ade532b33ea..00000000000000 --- a/test/known_issues/test-fs-cp-filter.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; - -// This test will fail because the implementation does not properly -// handle the case when the `src` or `dest` is a Buffer and the `filter` -// function is utilized when recursively copying directories. -// Refs: https://github.com/nodejs/node/issues/58634 -// Refs: https://github.com/nodejs/node/issues/58869 - -const common = require('../common'); - -const { - cpSync, - mkdirSync, -} = require('fs'); - -const { - join, -} = require('path'); - -const tmpdir = require('../common/tmpdir'); -tmpdir.refresh(); - -const pathA = join(tmpdir.path, 'a'); -const pathAC = join(pathA, 'c'); -const pathB = join(tmpdir.path, 'b'); -mkdirSync(pathAC, { recursive: true }); - -cpSync(Buffer.from(pathA), Buffer.from(pathB), { - recursive: true, - // This should be called multiple times, once for each file/directory, - // but it's only called once in this test because we're expecting this - // to fail. - filter: common.mustCall(() => true, 1), -}); diff --git a/test/known_issues/test-fs-cp-non-utf8.js b/test/known_issues/test-fs-cp-non-utf8.js deleted file mode 100644 index 814859be8ea63e..00000000000000 --- a/test/known_issues/test-fs-cp-non-utf8.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -// Refs: https://github.com/nodejs/node/issues/58634 -// Refs: https://github.com/nodejs/node/issues/58869 - -const common = require('../common'); - -if (!common.isLinux) { - common.skip('This test is only applicable to Linux'); -} - -const assert = require('assert'); -const { join } = require('path'); -const path = require('path'); -const tmpdir = require('../common/tmpdir'); -const { - cpSync, - existsSync, - mkdirSync, - writeFileSync, - readFileSync, -} = require('fs'); -tmpdir.refresh(); - -const tmpdirPath = Buffer.from(join(tmpdir.path, 'a', 'c')); -const sepBuf = Buffer.from(path.sep); - -mkdirSync(tmpdirPath, { recursive: true }); - -// The name is the Shift-JIS encoded version of こんにちは世界, -// or "Hello, World" in Japanese. On Linux systems, this name is -// a valid path name and should be handled correctly by the copy -// operation. However, the implementation of cp makes the assumption -// that the path names are UTF-8 encoded, so while we can create the -// file and check its existence using a Buffer, the recursive cp -// operation will fail because it tries to interpret every file name -// as UTF-8. -const name = Buffer.from([ - 0x82, 0xB1, 0x82, 0xF1, 0x82, 0xC9, 0x82, - 0xBF, 0x82, 0xCD, 0x90, 0x6C, 0x8C, 0x8E, -]); -const testPath = Buffer.concat([tmpdirPath, sepBuf, name]); - -writeFileSync(testPath, 'test content'); -assert.ok(existsSync(testPath)); -assert.strictEqual(readFileSync(testPath, 'utf8'), 'test content'); - -// The cpSync is expected to fail because the implementation does not -// properly handle non-UTF8 names in the path. - -cpSync(join(tmpdir.path, 'a'), join(tmpdir.path, 'b'), { - recursive: true, - filter: common.mustCall(() => true, 1), -}); diff --git a/test/parallel/test-fs-cp-buffer-paths.js b/test/parallel/test-fs-cp-buffer-paths.js new file mode 100644 index 00000000000000..0299f14b4b29c5 --- /dev/null +++ b/test/parallel/test-fs-cp-buffer-paths.js @@ -0,0 +1,497 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { + cp, + cpSync, + existsSync, + mkdirSync, + promises, + readFileSync, + readlinkSync, + symlinkSync, + writeFileSync, +} = require('fs'); +const { Buffer } = require('buffer'); +const { join, sep } = require('path'); +const { pathToFileURL } = require('url'); +const tmpdir = require('../common/tmpdir'); + +const sepBuffer = Buffer.from(sep); + +function bufferPath(...parts) { + const result = []; + for (let i = 0; i < parts.length; i++) { + if (i !== 0) result.push(sepBuffer); + result.push(Buffer.isBuffer(parts[i]) ? parts[i] : Buffer.from(parts[i])); + } + return Buffer.concat(result); +} + +function prepare(name) { + const root = join(tmpdir.path, name); + const src = join(root, 'src'); + const dest = join(root, 'dest'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'file.txt'), 'content'); + return { src, dest }; +} + +tmpdir.refresh(); + +{ + const root = join(tmpdir.path, 'buffer-files'); + const src = join(root, 'source.txt'); + const syncDest = join(root, 'sync.txt'); + const callbackDest = join(root, 'callback.txt'); + const promiseDest = join(root, 'promise.txt'); + mkdirSync(root, { recursive: true }); + writeFileSync(src, 'file-content'); + + cpSync(Buffer.from(src), Buffer.from(syncDest)); + assert.strictEqual(readFileSync(syncDest, 'utf8'), 'file-content'); + + cp(Buffer.from(src), Buffer.from(callbackDest), common.mustSucceed(() => { + assert.strictEqual(readFileSync(callbackDest, 'utf8'), 'file-content'); + })); + + promises.cp(Buffer.from(src), Buffer.from(promiseDest)).then(common.mustCall(() => { + assert.strictEqual(readFileSync(promiseDest, 'utf8'), 'file-content'); + })); +} + +{ + const { src, dest } = prepare('sync-buffer'); + const seen = []; + cpSync(Buffer.from(src), Buffer.from(dest), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert(Buffer.isBuffer(destPath)); + seen.push([srcPath, destPath]); + return true; + }, 2), + }); + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); + assert.strictEqual(seen.length, 2); +} + +{ + const { src, dest } = prepare('sync-string'); + cpSync(src, dest, { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert.strictEqual(typeof srcPath, 'string'); + assert.strictEqual(typeof destPath, 'string'); + return true; + }, 2), + }); + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); +} + +{ + const { src, dest } = prepare('sync-url'); + cpSync(pathToFileURL(src), pathToFileURL(dest), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert.strictEqual(typeof srcPath, 'string'); + assert.strictEqual(typeof destPath, 'string'); + return true; + }, 2), + }); + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); +} + +{ + const root = join(tmpdir.path, 'sync-buffer-normalized'); + const src = join(root, 'src'); + const dest = join(root, 'dest'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'file.txt'), 'normalized'); + const seen = []; + cpSync(Buffer.from(`${src}${sep}.${sep}`), Buffer.from(`${dest}${sep}.${sep}`), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + seen.push([srcPath, destPath]); + return true; + }, 2), + }); + assert(seen[1][0].equals(Buffer.from(join(src, 'file.txt')))); + assert(seen[1][1].equals(Buffer.from(join(dest, 'file.txt')))); +} + +{ + const { src, dest } = prepare('sync-uint8array'); + cpSync(new Uint8Array(Buffer.from(src)), new Uint8Array(Buffer.from(dest)), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert(Buffer.isBuffer(destPath)); + return true; + }, 2), + }); + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); +} + +{ + const { src, dest } = prepare('sync-mixed-buffer-string'); + cpSync(Buffer.from(src), dest, { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert.strictEqual(typeof destPath, 'string'); + return true; + }, 2), + }); + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); +} + +{ + const { src, dest } = prepare('sync-mixed-string-buffer'); + cpSync(src, Buffer.from(dest), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert.strictEqual(typeof srcPath, 'string'); + assert(Buffer.isBuffer(destPath)); + return true; + }, 2), + }); + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); +} + +{ + const { src, dest } = prepare('callback-buffer'); + cp(Buffer.from(src), Buffer.from(dest), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert(Buffer.isBuffer(destPath)); + return true; + }, 2), + }, common.mustSucceed(() => { + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); + })); +} + +{ + const { src, dest } = prepare('promise-buffer'); + promises.cp(Buffer.from(src), Buffer.from(dest), { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert(Buffer.isBuffer(destPath)); + return true; + }, 2), + }).then(common.mustCall(() => { + assert.strictEqual(readFileSync(join(dest, 'file.txt'), 'utf8'), 'content'); + })); +} + +if (common.isLinux) { + const root = join(tmpdir.path, 'non-utf8'); + const src = join(root, 'src'); + const dest = join(root, 'dest'); + const name = Buffer.from([ + 0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, + 0xbf, 0x82, 0xcd, 0x90, 0x6c, 0x8a, 0x45, + ]); + mkdirSync(src, { recursive: true }); + const srcFile = bufferPath(src, name); + const destFile = bufferPath(dest, name); + writeFileSync(srcFile, 'byte-exact'); + + let sawBytePath = false; + cpSync(src, dest, { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + if (Buffer.isBuffer(srcPath)) { + assert(Buffer.isBuffer(destPath)); + assert(srcPath.equals(srcFile)); + assert(destPath.equals(destFile)); + sawBytePath = true; + } + return true; + }, 2), + }); + + assert(sawBytePath); + assert(existsSync(destFile)); + assert.strictEqual(readFileSync(destFile, 'utf8'), 'byte-exact'); + + const urlSource = new URL(`${pathToFileURL(src).href}/${ + Array.from(name, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join('')}`); + const urlDest = new URL(`${pathToFileURL(dest).href}/url-copy`); + cpSync(urlSource, urlDest); + assert.strictEqual(readFileSync(join(dest, 'url-copy'), 'utf8'), 'byte-exact'); +} + + +if (common.isLinux) { + const invalidParent = Buffer.from([0x70, 0x61, 0xff, 0x72, 0x65, 0x6e, 0x74]); + const invalidChild = Buffer.from([0x63, 0x68, 0xfe, 0x69, 0x6c, 0x64]); + + { + const root = join(tmpdir.path, 'nested-non-utf8'); + const src = bufferPath(root, 'src'); + const dest = bufferPath(root, 'dest'); + const srcFile = bufferPath(src, invalidParent, invalidChild, 'file.txt'); + const destFile = bufferPath(dest, invalidParent, invalidChild, 'file.txt'); + mkdirSync(bufferPath(src, invalidParent, invalidChild), { recursive: true }); + writeFileSync(srcFile, 'nested-byte-exact'); + + cpSync(src, dest, { recursive: true }); + assert.strictEqual(readFileSync(destFile, 'utf8'), 'nested-byte-exact'); + } + + { + const root = join(tmpdir.path, 'mixed-path-types'); + const src = join(root, 'src'); + const dest = join(root, 'dest'); + const srcFile = bufferPath(src, invalidParent, 'file.txt'); + const destFile = bufferPath(dest, invalidParent, 'file.txt'); + mkdirSync(bufferPath(src, invalidParent), { recursive: true }); + writeFileSync(srcFile, 'mixed-types'); + + const seen = []; + cpSync(Buffer.from(src), dest, { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + seen.push([srcPath, destPath]); + return true; + }, 3), + }); + + assert.strictEqual(typeof seen[0][0], 'object'); + assert.strictEqual(typeof seen[0][1], 'string'); + assert(Buffer.isBuffer(seen[1][0])); + assert(Buffer.isBuffer(seen[1][1])); + assert.strictEqual(readFileSync(destFile, 'utf8'), 'mixed-types'); + } + + { + const root = join(tmpdir.path, 'self-copy-non-utf8'); + const src = bufferPath(root, invalidParent); + const dest = bufferPath(src, invalidChild); + mkdirSync(src, { recursive: true }); + writeFileSync(bufferPath(src, 'file.txt'), 'self-copy'); + + assert.throws( + () => cpSync(src, dest, { recursive: true }), + { code: 'ERR_FS_CP_EINVAL' }, + ); + } + + { + const root = join(tmpdir.path, 'callback-nested-non-utf8'); + const src = bufferPath(root, 'src'); + const dest = bufferPath(root, 'dest'); + const destFile = bufferPath(dest, invalidParent, invalidChild, 'file.txt'); + mkdirSync(bufferPath(src, invalidParent, invalidChild), { recursive: true }); + writeFileSync(bufferPath(src, invalidParent, invalidChild, 'file.txt'), 'callback-bytes'); + + cp(src, dest, { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert(Buffer.isBuffer(destPath)); + return true; + }, 4), + }, common.mustSucceed(() => { + assert.strictEqual(readFileSync(destFile, 'utf8'), 'callback-bytes'); + })); + } + + { + const root = join(tmpdir.path, 'promise-nested-non-utf8'); + const src = bufferPath(root, 'src'); + const dest = bufferPath(root, 'dest'); + const destFile = bufferPath(dest, invalidParent, invalidChild, 'file.txt'); + mkdirSync(bufferPath(src, invalidParent, invalidChild), { recursive: true }); + writeFileSync(bufferPath(src, invalidParent, invalidChild, 'file.txt'), 'promise-bytes'); + + promises.cp(src, dest, { + recursive: true, + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert(Buffer.isBuffer(destPath)); + return true; + }, 4), + }).then(common.mustCall(() => { + assert.strictEqual(readFileSync(destFile, 'utf8'), 'promise-bytes'); + })); + } + + { + const root = join(tmpdir.path, 'url-non-utf8-all-apis'); + const src = join(root, 'src'); + const dest = join(root, 'dest'); + mkdirSync(src, { recursive: true }); + const name = Buffer.from([0x66, 0x80, 0x6f]); + writeFileSync(bufferPath(src, name), 'url-bytes'); + const encodedName = Array.from( + name, + (byte) => `%${byte.toString(16).padStart(2, '0')}`, + ).join(''); + const source = new URL(`${pathToFileURL(src).href}/${encodedName}`); + const callbackDest = new URL(`${pathToFileURL(dest).href}/callback`); + const promiseDest = new URL(`${pathToFileURL(dest).href}/promise`); + + cp(source, callbackDest, { + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert.strictEqual(typeof destPath, 'string'); + return true; + }), + }, common.mustSucceed(() => { + assert.strictEqual(readFileSync(join(dest, 'callback'), 'utf8'), 'url-bytes'); + })); + promises.cp(source, promiseDest, { + filter: common.mustCall((srcPath, destPath) => { + assert(Buffer.isBuffer(srcPath)); + assert.strictEqual(typeof destPath, 'string'); + return true; + }), + }).then(common.mustCall(() => { + assert.strictEqual(readFileSync(join(dest, 'promise'), 'utf8'), 'url-bytes'); + })); + } + + { + const root = join(tmpdir.path, 'url-non-utf8-destinations'); + const src = join(root, 'source.txt'); + mkdirSync(root, { recursive: true }); + writeFileSync(src, 'destination-url-bytes'); + const names = [ + Buffer.from([0x73, 0x79, 0x6e, 0x63, 0x80]), + Buffer.from([0x63, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b, 0x81]), + Buffer.from([0x70, 0x72, 0x6f, 0x6d, 0x69, 0x73, 0x65, 0x82]), + ]; + const urls = names.map((name) => new URL( + Array.from( + name, + (byte) => `%${byte.toString(16).padStart(2, '0')}`, + ).join(''), + pathToFileURL(`${root}${sep}`), + )); + + cpSync(src, urls[0], { + filter: common.mustCall((srcPath, destPath) => { + assert.strictEqual(typeof srcPath, 'string'); + assert(Buffer.isBuffer(destPath)); + return true; + }), + }); + assert.strictEqual(readFileSync(bufferPath(root, names[0]), 'utf8'), 'destination-url-bytes'); + + cp(src, urls[1], { + filter: common.mustCall((srcPath, destPath) => { + assert.strictEqual(typeof srcPath, 'string'); + assert(Buffer.isBuffer(destPath)); + return true; + }), + }, common.mustSucceed(() => { + assert.strictEqual(readFileSync(bufferPath(root, names[1]), 'utf8'), 'destination-url-bytes'); + })); + + promises.cp(src, urls[2], { + filter: common.mustCall((srcPath, destPath) => { + assert.strictEqual(typeof srcPath, 'string'); + assert(Buffer.isBuffer(destPath)); + return true; + }), + }).then(common.mustCall(() => { + assert.strictEqual(readFileSync(bufferPath(root, names[2]), 'utf8'), 'destination-url-bytes'); + })); + } + + { + const root = join(tmpdir.path, 'async-self-copy-non-utf8'); + const callbackSrc = bufferPath(root, 'callback', invalidParent); + const callbackDest = bufferPath(callbackSrc, invalidChild); + mkdirSync(callbackSrc, { recursive: true }); + writeFileSync(bufferPath(callbackSrc, 'file.txt'), 'callback-self-copy'); + cp(callbackSrc, callbackDest, { recursive: true }, common.mustCall((err) => { + assert.strictEqual(err?.code, 'ERR_FS_CP_EINVAL'); + })); + + const promiseSrc = bufferPath(root, 'promise', invalidParent); + const promiseDest = bufferPath(promiseSrc, invalidChild); + mkdirSync(promiseSrc, { recursive: true }); + writeFileSync(bufferPath(promiseSrc, 'file.txt'), 'promise-self-copy'); + assert.rejects( + promises.cp(promiseSrc, promiseDest, { recursive: true }), + { code: 'ERR_FS_CP_EINVAL' }, + ).then(common.mustCall()); + } + + { + const root = join(tmpdir.path, 'symlink-non-utf8'); + const srcDir = bufferPath(root, 'src'); + const destDir = bufferPath(root, 'dest'); + mkdirSync(srcDir, { recursive: true }); + const target = invalidChild; + writeFileSync(bufferPath(srcDir, target), 'symlink-target'); + symlinkSync(target, bufferPath(srcDir, 'link')); + + cpSync(srcDir, destDir, { recursive: true, verbatimSymlinks: true }); + assert(readlinkSync(bufferPath(destDir, 'link'), { encoding: 'buffer' }).equals(target)); + + const resolvedDestDir = bufferPath(root, 'resolved-dest'); + cpSync(srcDir, resolvedDestDir, { recursive: true }); + assert( + readlinkSync(bufferPath(resolvedDestDir, 'link'), { encoding: 'buffer' }) + .equals(bufferPath(srcDir, target)), + ); + } +} + + +if (!common.isWindows) { + const root = join(tmpdir.path, 'verbatim-existing-relative-symlink'); + const target = join(root, 'target'); + mkdirSync(join(target, 'child'), { recursive: true }); + + const src = join(root, 'src'); + const dest = join(root, 'dest'); + symlinkSync('target/child', src); + symlinkSync('target', dest); + assert.rejects( + promises.cp(Buffer.from(src), Buffer.from(dest), { + verbatimSymlinks: true, + }), + { code: 'ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY' }, + ).then(common.mustCall()); +} + +if (common.isLinux) { + const root = join(tmpdir.path, 'absolute-symlink-non-utf8'); + const srcDir = bufferPath(root, 'src'); + const destDir = bufferPath(root, 'dest'); + const invalidName = Buffer.from([0x74, 0x61, 0x72, 0xff, 0x67, 0x65, 0x74]); + const absoluteTarget = bufferPath(root, 'unused', '..', invalidName); + + mkdirSync(srcDir, { recursive: true }); + symlinkSync(absoluteTarget, bufferPath(srcDir, 'link')); + cpSync(srcDir, destDir, { recursive: true }); + assert( + readlinkSync(bufferPath(destDir, 'link'), { encoding: 'buffer' }) + .equals(absoluteTarget), + ); +} + + +if (common.isLinux) { + const root = join(tmpdir.path, 'unicode-\u03c0-parent'); + const src = join(root, 'src'); + const dest = join(root, 'dest'); + const invalidName = Buffer.from([0x66, 0x69, 0xff, 0x6c, 0x65]); + mkdirSync(src, { recursive: true }); + writeFileSync(bufferPath(src, invalidName), 'unicode-parent'); + + cpSync(src, dest, { recursive: true }); + assert.strictEqual( + readFileSync(bufferPath(dest, invalidName), 'utf8'), + 'unicode-parent', + ); +} diff --git a/test/parallel/test-fs-cp-path.js b/test/parallel/test-fs-cp-path.js new file mode 100644 index 00000000000000..c8114013cb204c --- /dev/null +++ b/test/parallel/test-fs-cp-path.js @@ -0,0 +1,131 @@ +'use strict'; + +// Flags: --expose-internals + +const common = require('../common'); +const assert = require('assert'); +const { Buffer } = require('buffer'); +const { mkdirSync } = require('fs'); +const path = require('path'); +const { join, sep } = path; +const tmpdir = require('../common/tmpdir'); +const { + dirnamePath, + isPathRoot, + isSrcSubdir, + joinPath, + maybeDecodePath, + pathEquals, + resolveLinkTarget, + resolvePath, +} = require('internal/fs/cp/path'); + +tmpdir.refresh(); + +const paths = [ + '', + '.', + '..', + '/', + '//', + '///', + 'a', + 'a/', + '/a', + '//a', + 'a/b', + '/a/b', + 'a//b', + 'a/./b', + 'a/../b', + '../a', + '../../a', + '/a/../b', + '/..', + '/../../a', +]; + +for (const value of paths) { + const buffer = Buffer.from(value); + assert.strictEqual(dirnamePath(buffer).toString(), path.dirname(value)); + assert.strictEqual(resolvePath(buffer).toString(), path.resolve(value)); + assert.strictEqual(isPathRoot(buffer), path.parse(path.resolve(value)).root === path.resolve(value)); +} + +for (const parent of paths) { + for (const name of ['x', 'xy', '.x', '..x']) { + assert.strictEqual( + joinPath(Buffer.from(parent), Buffer.from(name)).toString(), + path.join(parent, name), + ); + } +} + +for (const src of paths) { + for (const dest of paths) { + const srcParts = path.resolve(src).split(path.sep).filter(Boolean); + const destParts = path.resolve(dest).split(path.sep).filter(Boolean); + const expected = srcParts.every((part, index) => destParts[index] === part); + assert.strictEqual( + isSrcSubdir(Buffer.from(src), Buffer.from(dest)), + expected, + ); + } +} + +assert(pathEquals('abc', Buffer.from('abc'))); +assert.strictEqual(maybeDecodePath(Buffer.from('abc'), true), 'abc'); + +if (!common.isWindows) { + const parent = Buffer.from('/tmp/parent'); + const name = Buffer.from([0x66, 0x80, 0x6f]); + const child = Buffer.concat([parent, Buffer.from('/'), name]); + const grandchild = Buffer.concat([child, Buffer.from('/child')]); + + assert(joinPath(parent, name).equals(child)); + assert(dirnamePath(child).equals(parent)); + assert(isSrcSubdir(child, grandchild)); + assert(!isSrcSubdir(grandchild, child)); + assert.strictEqual(maybeDecodePath(name, true), name); + assert(resolveLinkTarget(parent, name).equals(child)); +} + +if (!common.isWindows) { + const absoluteStringTarget = '/tmp//target/../file'; + const absoluteBufferTarget = Buffer.from('/tmp//opaque/../target'); + const invalidParent = Buffer.from([0x2f, 0x74, 0x6d, 0x70, 0x2f, 0xff]); + + assert.strictEqual( + resolveLinkTarget('/source', absoluteStringTarget), + absoluteStringTarget, + ); + assert( + resolveLinkTarget(invalidParent, absoluteBufferTarget) + .equals(absoluteBufferTarget), + ); +} + + +if (!common.isWindows) { + const originalCwd = process.cwd(); + const unicodeCwd = join(tmpdir.path, 'unicode-\u03c0'); + mkdirSync(unicodeCwd, { recursive: true }); + process.chdir(unicodeCwd); + try { + assert( + resolvePath(Buffer.from('child')) + .equals(Buffer.from(join(unicodeCwd, 'child'))), + ); + const invalidName = Buffer.from([0x63, 0x68, 0xff, 0x69, 0x6c, 0x64]); + assert( + joinPath(unicodeCwd, invalidName) + .equals(Buffer.concat([ + Buffer.from(unicodeCwd), + Buffer.from(sep), + invalidName, + ])), + ); + } finally { + process.chdir(originalCwd); + } +}