From 7c3cdf1560ce93beb108b93eea237208b51e6a4d Mon Sep 17 00:00:00 2001 From: shulaoda <165626830+shulaoda@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:19:17 +0800 Subject: [PATCH 1/2] fs: pass symlink type in cp when filter is provided --- lib/internal/fs/cp/cp-sync.js | 13 ++--- lib/internal/fs/cp/cp.js | 14 +++--- ...sync-verbatim-dir-symlinks-with-filter.mjs | 47 +++++++++++++++++++ ...sync-verbatim-dir-symlinks-with-filter.mjs | 47 +++++++++++++++++++ 4 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs create mode 100644 test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs diff --git a/lib/internal/fs/cp/cp-sync.js b/lib/internal/fs/cp/cp-sync.js index 03fcae9b7cdbda..f2b00f3f82bb9a 100644 --- a/lib/internal/fs/cp/cp-sync.js +++ b/lib/internal/fs/cp/cp-sync.js @@ -191,8 +191,10 @@ function onLink(destStat, src, dest, verbatimSymlinks) { if (!verbatimSymlinks && !isAbsolute(resolvedSrc)) { resolvedSrc = resolve(dirname(src), resolvedSrc); } + const srcIsDir = fsBinding.internalModuleStat(src) === 1; + const symlinkType = srcIsDir ? 'dir' : 'file'; if (!destStat) { - return symlinkSync(resolvedSrc, dest); + return symlinkSync(resolvedSrc, dest, symlinkType); } let resolvedDest; try { @@ -202,14 +204,13 @@ function onLink(destStat, src, dest, verbatimSymlinks) { // 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); + return symlinkSync(resolvedSrc, dest, symlinkType); } throw err; } if (!isAbsolute(resolvedDest)) { resolvedDest = resolve(dirname(dest), resolvedDest); } - const srcIsDir = fsBinding.internalModuleStat(src) === 1; if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) { throw new ERR_FS_CP_EINVAL({ @@ -233,12 +234,12 @@ function onLink(destStat, src, dest, verbatimSymlinks) { code: 'EINVAL', }); } - return copyLink(resolvedSrc, dest); + return copyLink(resolvedSrc, dest, symlinkType); } -function copyLink(resolvedSrc, dest) { +function copyLink(resolvedSrc, dest, symlinkType) { unlinkSync(dest); - return symlinkSync(resolvedSrc, dest); + return symlinkSync(resolvedSrc, dest, symlinkType); } module.exports = { cpSyncFn }; diff --git a/lib/internal/fs/cp/cp.js b/lib/internal/fs/cp/cp.js index 10c52b11463445..2eb4f0ffdd83ca 100644 --- a/lib/internal/fs/cp/cp.js +++ b/lib/internal/fs/cp/cp.js @@ -336,8 +336,10 @@ async function onLink(destStat, src, dest, opts) { if (!opts.verbatimSymlinks && !isAbsolute(resolvedSrc)) { resolvedSrc = resolve(dirname(src), resolvedSrc); } + const srcIsDir = fsBinding.internalModuleStat(src) === 1; + const symlinkType = srcIsDir ? 'dir' : 'file'; if (!destStat) { - return symlink(resolvedSrc, dest); + return symlink(resolvedSrc, dest, symlinkType); } let resolvedDest; try { @@ -347,7 +349,7 @@ async function onLink(destStat, src, dest, opts) { // 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); + return symlink(resolvedSrc, dest, symlinkType); } throw err; } @@ -355,8 +357,6 @@ async function onLink(destStat, src, dest, opts) { resolvedDest = resolve(dirname(dest), resolvedDest); } - const srcIsDir = fsBinding.internalModuleStat(src) === 1; - if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) { throw new ERR_FS_CP_EINVAL({ message: `cannot copy ${resolvedSrc} to a subdirectory of self ` + @@ -380,12 +380,12 @@ async function onLink(destStat, src, dest, opts) { code: 'EINVAL', }); } - return copyLink(resolvedSrc, dest); + return copyLink(resolvedSrc, dest, symlinkType); } -async function copyLink(resolvedSrc, dest) { +async function copyLink(resolvedSrc, dest, symlinkType) { await unlink(dest); - return symlink(resolvedSrc, dest); + return symlink(resolvedSrc, dest, symlinkType); } module.exports = { diff --git a/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs b/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs new file mode 100644 index 00000000000000..0d8b063d26d672 --- /dev/null +++ b/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs @@ -0,0 +1,47 @@ +// This tests that cp with verbatimSymlinks and filter preserves +// the directory symlink type on Windows (does not create a file symlink). +import { mustNotMutateObjectDeep, isWindows } from '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import assert from 'node:assert'; +import { + mkdirSync, + writeFileSync, + symlinkSync, + readlinkSync, + readdirSync, + statSync, +} from 'node:fs'; +import { cp } from 'node:fs/promises'; +import { join } from 'node:path'; + +import tmpdir from '../common/tmpdir.js'; +tmpdir.refresh(); + +// Setup source with a relative directory symlink +const src = nextdir(); +mkdirSync(join(src, 'packages', 'my-lib'), mustNotMutateObjectDeep({ recursive: true })); +writeFileSync(join(src, 'packages', 'my-lib', 'index.js'), 'module.exports = "hello"'); +mkdirSync(join(src, 'linked'), mustNotMutateObjectDeep({ recursive: true })); +symlinkSync(join('..', 'packages', 'my-lib'), join(src, 'linked', 'my-lib'), 'dir'); + +// Copy with verbatimSymlinks: true AND a filter function +const dest = nextdir(); +await cp(src, dest, mustNotMutateObjectDeep({ + recursive: true, + verbatimSymlinks: true, + filter: () => true, +})); + +// Verify the symlink target is preserved verbatim +const link = readlinkSync(join(dest, 'linked', 'my-lib')); +if (isWindows) { + assert.strictEqual(link.toLowerCase(), join('..', 'packages', 'my-lib').toLowerCase()); +} else { + assert.strictEqual(link, join('..', 'packages', 'my-lib')); +} + +// Verify the symlink works as a directory (not a file symlink) +const destSymlink = join(dest, 'linked', 'my-lib'); +assert.ok(statSync(destSymlink).isDirectory(), + 'symlink target should be accessible as a directory'); +assert.deepStrictEqual(readdirSync(destSymlink), ['index.js']); diff --git a/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs b/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs new file mode 100644 index 00000000000000..a761ee2e98627c --- /dev/null +++ b/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs @@ -0,0 +1,47 @@ +// This tests that cpSync with verbatimSymlinks and filter preserves +// the directory symlink type on Windows (does not create a file symlink). +import { mustNotMutateObjectDeep, isWindows } from '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import assert from 'node:assert'; +import { + cpSync, + mkdirSync, + writeFileSync, + symlinkSync, + readlinkSync, + readdirSync, + statSync, +} from 'node:fs'; +import { join } from 'node:path'; + +import tmpdir from '../common/tmpdir.js'; +tmpdir.refresh(); + +// Setup source with a relative directory symlink +const src = nextdir(); +mkdirSync(join(src, 'packages', 'my-lib'), mustNotMutateObjectDeep({ recursive: true })); +writeFileSync(join(src, 'packages', 'my-lib', 'index.js'), 'module.exports = "hello"'); +mkdirSync(join(src, 'linked'), mustNotMutateObjectDeep({ recursive: true })); +symlinkSync(join('..', 'packages', 'my-lib'), join(src, 'linked', 'my-lib'), 'dir'); + +// Copy with verbatimSymlinks: true AND a filter function +const dest = nextdir(); +cpSync(src, dest, mustNotMutateObjectDeep({ + recursive: true, + verbatimSymlinks: true, + filter: () => true, +})); + +// Verify the symlink target is preserved verbatim +const link = readlinkSync(join(dest, 'linked', 'my-lib')); +if (isWindows) { + assert.strictEqual(link.toLowerCase(), join('..', 'packages', 'my-lib').toLowerCase()); +} else { + assert.strictEqual(link, join('..', 'packages', 'my-lib')); +} + +// Verify the symlink works as a directory (not a file symlink) +const destSymlink = join(dest, 'linked', 'my-lib'); +assert.ok(statSync(destSymlink).isDirectory(), + 'symlink target should be accessible as a directory'); +assert.deepStrictEqual(readdirSync(destSymlink), ['index.js']); From 993264d5d35a9edad7fefc2f01a1badad941e4b5 Mon Sep 17 00:00:00 2001 From: shulaoda <165626830+shulaoda@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:48:34 +0800 Subject: [PATCH 2/2] lint-js --- .../test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs | 3 +-- .../test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs b/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs index 0d8b063d26d672..8e773bc6065c57 100644 --- a/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs +++ b/test/parallel/test-fs-cp-async-verbatim-dir-symlinks-with-filter.mjs @@ -42,6 +42,5 @@ if (isWindows) { // Verify the symlink works as a directory (not a file symlink) const destSymlink = join(dest, 'linked', 'my-lib'); -assert.ok(statSync(destSymlink).isDirectory(), - 'symlink target should be accessible as a directory'); +assert.ok(statSync(destSymlink).isDirectory(), 'symlink target should be accessible as a directory'); assert.deepStrictEqual(readdirSync(destSymlink), ['index.js']); diff --git a/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs b/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs index a761ee2e98627c..7aa0d90ff2a4c2 100644 --- a/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs +++ b/test/parallel/test-fs-cp-sync-verbatim-dir-symlinks-with-filter.mjs @@ -42,6 +42,5 @@ if (isWindows) { // Verify the symlink works as a directory (not a file symlink) const destSymlink = join(dest, 'linked', 'my-lib'); -assert.ok(statSync(destSymlink).isDirectory(), - 'symlink target should be accessible as a directory'); +assert.ok(statSync(destSymlink).isDirectory(), 'symlink target should be accessible as a directory'); assert.deepStrictEqual(readdirSync(destSymlink), ['index.js']);