Skip to content

fs.cp creates file symlink instead of directory symlink on Windows when filter is provided #62653

@shulaoda

Description

@shulaoda

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    windowsIssues and PRs related to the Windows platform.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions