diff --git a/doc/api/fs.md b/doc/api/fs.md index 1c08f2d5267066..17d15c51c5454d 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -827,6 +827,11 @@ try { > Stability: 1 - Experimental @@ -847,6 +852,8 @@ added: v16.7.0 * `preserveTimestamps` {boolean} When `true` timestamps from `src` will be preserved. **Default:** `false`. * `recursive` {boolean} copy directories recursively **Default:** `false` + * `verbatimSymlinks` {boolean} When `true`, path resolution for symlinks will + be skipped. **Default:** `false` * Returns: {Promise} Fulfills with `undefined` upon success. Asynchronously copies the entire directory structure from `src` to `dest`, @@ -2006,6 +2013,11 @@ copyFile('source.txt', 'destination.txt', constants.COPYFILE_EXCL, callback); > Stability: 1 - Experimental @@ -2026,6 +2038,8 @@ added: v16.7.0 * `preserveTimestamps` {boolean} When `true` timestamps from `src` will be preserved. **Default:** `false`. * `recursive` {boolean} copy directories recursively **Default:** `false` + * `verbatimSymlinks` {boolean} When `true`, path resolution for symlinks will + be skipped. **Default:** `false` * `callback` {Function} Asynchronously copies the entire directory structure from `src` to `dest`, @@ -4581,6 +4595,11 @@ copyFileSync('source.txt', 'destination.txt', constants.COPYFILE_EXCL); > Stability: 1 - Experimental @@ -4600,6 +4619,8 @@ added: v16.7.0 * `preserveTimestamps` {boolean} When `true` timestamps from `src` will be preserved. **Default:** `false`. * `recursive` {boolean} copy directories recursively **Default:** `false` + * `verbatimSymlinks` {boolean} When `true`, path resolution for symlinks will + be skipped. **Default:** `false` Synchronously copies the entire directory structure from `src` to `dest`, including subdirectories and files. diff --git a/lib/internal/fs/cp/cp-sync.js b/lib/internal/fs/cp/cp-sync.js index 497c8c57c317dd..f9d159a193107e 100644 --- a/lib/internal/fs/cp/cp-sync.js +++ b/lib/internal/fs/cp/cp-sync.js @@ -182,7 +182,7 @@ function getStats(destStat, src, dest, opts) { srcStat.isBlockDevice()) { return onFile(srcStat, destStat, src, dest, opts); } else if (srcStat.isSymbolicLink()) { - return onLink(destStat, src, dest); + return onLink(destStat, src, dest, opts); } else if (srcStat.isSocket()) { throw new ERR_FS_CP_SOCKET({ message: `cannot copy a socket file: ${dest}`, @@ -293,9 +293,9 @@ function copyDir(src, dest, opts) { } } -function onLink(destStat, src, dest) { +function onLink(destStat, src, dest, opts) { let resolvedSrc = readlinkSync(src); - if (!isAbsolute(resolvedSrc)) { + if (!opts.verbatimSymlinks && !isAbsolute(resolvedSrc)) { resolvedSrc = resolve(dirname(src), resolvedSrc); } if (!destStat) { diff --git a/lib/internal/fs/cp/cp.js b/lib/internal/fs/cp/cp.js index 6dc212f2f6a5fc..bcbc8aa3279a50 100644 --- a/lib/internal/fs/cp/cp.js +++ b/lib/internal/fs/cp/cp.js @@ -222,7 +222,7 @@ async function getStatsForCopy(destStat, src, dest, opts) { srcStat.isBlockDevice()) { return onFile(srcStat, destStat, src, dest, opts); } else if (srcStat.isSymbolicLink()) { - return onLink(destStat, src, dest); + return onLink(destStat, src, dest, opts); } else if (srcStat.isSocket()) { throw new ERR_FS_CP_SOCKET({ message: `cannot copy a socket file: ${dest}`, @@ -335,9 +335,9 @@ async function copyDir(src, dest, opts) { } } -async function onLink(destStat, src, dest) { +async function onLink(destStat, src, dest, opts) { let resolvedSrc = await readlink(src); - if (!isAbsolute(resolvedSrc)) { + if (!opts.verbatimSymlinks && !isAbsolute(resolvedSrc)) { resolvedSrc = resolve(dirname(src), resolvedSrc); } if (!destStat) { diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index 66b5e39b0c0fc8..481b5292b1d726 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -29,6 +29,7 @@ const { codes: { ERR_FS_EISDIR, ERR_FS_INVALID_SYMLINK_TYPE, + ERR_INCOMPATIBLE_OPTION_PAIR, ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_OUT_OF_RANGE @@ -724,6 +725,7 @@ const defaultCpOptions = { force: true, preserveTimestamps: false, recursive: false, + verbatimSymlinks: false, }; const defaultRmOptions = { @@ -749,6 +751,10 @@ const validateCpOptions = hideStackFrames((options) => { validateBoolean(options.force, 'options.force'); validateBoolean(options.preserveTimestamps, 'options.preserveTimestamps'); validateBoolean(options.recursive, 'options.recursive'); + validateBoolean(options.verbatimSymlinks, 'options.verbatimSymlinks'); + if (options.dereference === true && options.verbatimSymlinks === true) { + throw new ERR_INCOMPATIBLE_OPTION_PAIR('dereference', 'verbatimSymlinks'); + } if (options.filter !== undefined) { validateFunction(options.filter, 'options.filter'); } diff --git a/test/parallel/test-fs-cp.mjs b/test/parallel/test-fs-cp.mjs index 804b5a1f4c322c..dfe6254c6b6494 100644 --- a/test/parallel/test-fs-cp.mjs +++ b/test/parallel/test-fs-cp.mjs @@ -95,6 +95,77 @@ function nextdir() { } +// It throws error when verbatimSymlinks is not a boolean. +{ + const src = './test/fixtures/copy/kitchen-sink'; + [1, [], {}, null, 1n, undefined, null, Symbol(), '', () => {}] + .forEach((verbatimSymlinks) => { + assert.throws( + () => cpSync(src, src, { verbatimSymlinks }), + { code: 'ERR_INVALID_ARG_TYPE' } + ); + }); +} + + +// It throws an error when both dereference and verbatimSymlinks are enabled. +{ + const src = './test/fixtures/copy/kitchen-sink'; + assert.throws( + () => cpSync(src, src, { dereference: true, verbatimSymlinks: true }), + { code: 'ERR_INCOMPATIBLE_OPTION_PAIR' } + ); +} + + +// It resolves relative symlinks to their absolute path by default. +{ + const src = nextdir(); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync('foo.js', join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + + cpSync(src, dest, { recursive: true }); + const link = readlinkSync(join(dest, 'bar.js')); + assert.strictEqual(link, join(src, 'foo.js')); +} + + +// It resolves relative symlinks when verbatimSymlinks is false. +{ + const src = nextdir(); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync('foo.js', join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + + cpSync(src, dest, { recursive: true, verbatimSymlinks: false }); + const link = readlinkSync(join(dest, 'bar.js')); + assert.strictEqual(link, join(src, 'foo.js')); +} + + +// It does not resolve relative symlinks when verbatimSymlinks is true. +{ + const src = nextdir(); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'foo.js'), 'foo', 'utf8'); + symlinkSync('foo.js', join(src, 'bar.js')); + + const dest = nextdir(); + mkdirSync(dest, { recursive: true }); + + cpSync(src, dest, { recursive: true, verbatimSymlinks: true }); + const link = readlinkSync(join(dest, 'bar.js')); + assert.strictEqual(link, 'foo.js'); +} + + // It throws error when src and dest are identical. { const src = './test/fixtures/copy/kitchen-sink';