Version
v24.14.1 (also affects v25.9.0)
Platform
Windows 11 Pro 10.0.26200
What steps will reproduce the bug?
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const tmp = path.join(os.tmpdir(), 'repro-' + Date.now());
const src = path.join(tmp, 'src');
const dest1 = path.join(tmp, 'dest-no-filter');
const dest2 = path.join(tmp, 'dest-with-filter');
// Create a source directory with a relative directory symlink
fs.mkdirSync(path.join(src, 'packages', 'my-lib'), { recursive: true });
fs.writeFileSync(path.join(src, 'packages', 'my-lib', 'index.js'), 'hello');
fs.mkdirSync(path.join(src, 'linked'), { recursive: true });
fs.symlinkSync(
path.join('..', 'packages', 'my-lib'),
path.join(src, 'linked', 'my-lib'),
'dir'
);
// Test 1: verbatimSymlinks WITHOUT filter — works
fs.cpSync(src, dest1, { recursive: true, verbatimSymlinks: true });
console.log('without filter:', fs.statSync(path.join(dest1, 'linked', 'my-lib')).isDirectory()); // true
// Test 2: verbatimSymlinks WITH filter — broken
fs.cpSync(src, dest2, { recursive: true, verbatimSymlinks: true, filter: () => true });
console.log('with filter:', fs.statSync(path.join(dest2, 'linked', 'my-lib')).isDirectory()); // EPERM
fs.rmSync(tmp, { recursive: true, force: true });
How often does it reproduce? Is there a required condition?
Always reproducible on Windows. Requires verbatimSymlinks: true combined with a filter function (even () => true).
What is the expected behavior? Why is that the expected behavior?
Both cases should produce a working directory symlink. The filter option should not affect the symlink type — filter: () => true is semantically identical to no filter.
What do you see instead?
Without filter: the directory symlink is preserved correctly (Directory, ReparsePoint).
With filter: a file symlink is created instead (Archive, ReparsePoint). Subsequent stat(), readdir(), and realpathSync() on the copied symlink all fail with EPERM.
Root cause
fs.cp takes two different code paths depending on whether filter is provided:
-
Without filter: Uses the C++ fast path (fsBinding.cpSyncCopyDir) which calls std::filesystem::copy_symlink() — this automatically preserves the symlink type.
-
With filter: Falls back to the JavaScript path in lib/internal/fs/cp/cp-sync.js, where onLink() calls symlinkSync(resolvedSrc, dest) without the type parameter.
When type is omitted, symlinkSync tries to auto-detect by calling statSync(resolve(dest, '..', target)). However, during a recursive copy, the symlink's target directory may not yet exist at the destination (e.g., linked/ is copied before packages/ in alphabetical order). When stat fails, type defaults to null, which results in a file symlink on Windows.
The same issue exists in the async version (lib/internal/fs/cp/cp.js).
Related Issue
Version
v24.14.1 (also affects v25.9.0)
Platform
Windows 11 Pro 10.0.26200
What steps will reproduce the bug?
How often does it reproduce? Is there a required condition?
Always reproducible on Windows. Requires
verbatimSymlinks: truecombined with afilterfunction (even() => true).What is the expected behavior? Why is that the expected behavior?
Both cases should produce a working directory symlink. The
filteroption should not affect the symlink type —filter: () => trueis semantically identical to no filter.What do you see instead?
Without
filter: the directory symlink is preserved correctly (Directory, ReparsePoint).With
filter: a file symlink is created instead (Archive, ReparsePoint). Subsequentstat(),readdir(), andrealpathSync()on the copied symlink all fail withEPERM.Root cause
fs.cptakes two different code paths depending on whetherfilteris provided:Without
filter: Uses the C++ fast path (fsBinding.cpSyncCopyDir) which callsstd::filesystem::copy_symlink()— this automatically preserves the symlink type.With
filter: Falls back to the JavaScript path inlib/internal/fs/cp/cp-sync.js, whereonLink()callssymlinkSync(resolvedSrc, dest)without thetypeparameter.When
typeis omitted,symlinkSynctries to auto-detect by callingstatSync(resolve(dest, '..', target)). However, during a recursive copy, the symlink's target directory may not yet exist at the destination (e.g.,linked/is copied beforepackages/in alphabetical order). When stat fails,typedefaults tonull, which results in a file symlink on Windows.The same issue exists in the async version (
lib/internal/fs/cp/cp.js).Related Issue