feat: 🎸 implement Node-compatible cp module - #1294
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Multiple unresolved critical and moderate correctness and API issues remain in cp.ts and FsPromises.ts.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Implements a shared Node-compatible cp and copyFile engine for synchronous, callback, and promise APIs.
Changes:
- Adds recursive copying, filtering, symlink handling, validation, timestamps, and error mapping.
- Integrates the implementation with
VolumeandFsPromises. - Updates copy-related tests and edge-case coverage.
File summaries
| File | Summary and final findings |
|---|---|
packages/fs-node/src/volume.ts |
Integrates the new copy APIs. |
packages/fs-node/src/FsPromises.ts |
Line 167, moderate (3 votes): make cp options optional. Line 172, moderate (3 votes): make copyFile flags optional. |
packages/fs-node/src/cp.ts |
Unresolved findings: line 230, critical (3 votes), incorrect dereference preflight; line 213, moderate (1 vote), identity checks ignore dereference mode; line 348, moderate (1 vote), synchronous recursion lacks child preflight; lines 372 and 552, moderate (3 votes) and critical (3 votes), symlink replacement does not unlink existing destinations; line 324, moderate (1 vote), synchronous errorOnExist is ignored; line 566, moderate (1 vote), incorrect symlink-to-subdirectory check; line 236, moderate (2 votes), non-ENOENT destination errors are swallowed; line 267, moderate (1 vote), parent-creation errors are swallowed; line 138, critical (1 vote), forced clone validation can delete an existing destination; line 258, critical (1 vote), root-source recursion is allowed; line 96, moderate (1 vote), explicit undefined options overwrite defaults; line 408, nit (1 vote), async filter promise support is missing from the public type. |
packages/fs-node/src/__tests__/volume/promises-cp.test.ts |
Updates promise API copy coverage. |
packages/fs-node/src/__tests__/volume/cpSync.test.ts |
Updates synchronous copy coverage. |
packages/fs-node/src/__tests__/volume/cp.test.ts |
Updates callback copy coverage. |
packages/fs-node/src/__tests__/volume/cp-edge-cases.test.ts |
Adds and updates symlink and edge-case coverage. |
Review details
Suppressed comments (7)
packages/fs-node/src/cp.ts:217
identicalPathsalways usesstatSync, even when cp is preserving symlinks.checkPathsSyncuses it both for the src/dest identity check and for detecting a destination parent under the source, so distinct symlinks to the same target (or a symlink copied into its target directory) are incorrectly rejected withEINVALwhendereferenceis false. Make this helper honor the selected dereference mode or use the already-selected stats.
const identicalPaths = (vol: Volume, a: string, b: string): boolean => {
try {
const statA = vol.statSync(a, { throwIfNoEntry: false }) as Stats | undefined;
if (!statA) return false;
const statB = vol.statSync(b, { throwIfNoEntry: false }) as Stats | undefined;
packages/fs-node/src/cp.ts:348
- The synchronous recursive path calls
getStatsSyncdirectly for children, unlike the async path'scheckPaths. Consequently, a nested file/directory type conflict takes the wrong operation (for example, tries to unlink a directory), and nested FIFO/socket/unknown entries are silently skipped instead of producing the cp error. Run the same path/type preflight for each child before copying it.
getStatsSync(vol, srcItem, destItem, options);
packages/fs-node/src/cp.ts:325
- Unlike the async
onDir, this branch never checkserrorOnExistwhen the destination directory already exists. ThereforecpSync(src, dest, { recursive: true, force: false, errorOnExist: true })silently merges intodestinstead of throwingERR_FS_CP_EEXIST, including for existing nested directories. Mirror the async destination check before recursing.
if (!destStat) copyDirSync(vol, src, dest, options, true, Number(srcStat.mode));
else copyDirSync(vol, src, dest, options, false, undefined);
packages/fs-node/src/cp.ts:566
- This check is testing the source target, but the
ERR_FS_CP_SYMLINK_TO_SUBDIRECTORYcondition is about whether the existing destination is a directory. Besides rejecting the wrong topology,statOf(vol, src, true)makes copying a dangling source symlink over an existing symlink fail withENOENTeven thoughdereferencedefaults to false. Use the destination directory check, matchingonLinkSync.
if ((statOf(vol, src, true) as Stats).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc))
packages/fs-node/src/cp.ts:269
- This catch discards failures while creating the destination parent. Permission and
ENOTDIRerrors are therefore hidden and the later copy can report a misleading error (or mutate a partial tree) instead of the parent-creation failure. LetmkdirSyncpropagate non-ENOENTerrors.
try {
if (!destStat || !vol.existsSync(destParent)) vol.mkdirSync(destParent, { recursive: true });
} catch {}
packages/fs-node/src/cp.ts:96
- Spreading
optionsover the defaults lets an explicitly suppliedundefinedoverwrite each boolean default, after which the validators reject calls such as{ recursive: undefined }. Node treats an undefined option property as omitted, and the previous implementation used nullish defaults; normalize undefined values before validating.
const resolved: ICpOptionsResolved = { ...CP_DEFAULTS, ...options } as ICpOptionsResolved;
packages/fs-node/src/cp.ts:408
- The async implementation awaits
filter, so it supports a Promise-returning filter at runtime, but the publicICpOptions.filtertype still only returnsboolean(fs-node-utils/src/types/options.ts:193). TypeScript consumers cannot use this new async behavior without a cast; widen the async-facing option type or provide separate sync and async option types.
if (filter && !(await filter(src, dest))) return undefined;
- Files reviewed: 7/7 changed files
- Comments generated: 8
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved correctness issues remain in source validation, symlink handling, error handling, and recursive copy safeguards.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (10)
Previously missed (1) — in code that hasn't changed since the last review.
packages/fs-node/src/cp.ts:209
- Relative symlink targets are resolved with
posix.resolve's host cwd. Volume operations resolve relative paths against the instance'sSuperblock.process.cwd(), so a volume with a custom cwd can create an absolute link target outside the virtual filesystem when copying a relative source link.
packages/fs-node/src/cp.ts:230
!dereferencereverses the source stat mode: withdereference: truethis still lstat's the symlink. The subsequent type checks can classify a symlink-to-file as a directory and reject or overwrite the wrong destination; the async path passesdereferencedirectly.
srcStat = statOf(vol, src, !dereference);
packages/fs-node/src/cp.ts:244
identicalPathsalways follows symlinks, so sync cp treats distinct symlinks (or a source file and a destination symlink to it) as the same path even whendereferenceis false. Valid symlink copies then fail; compare the already selectedsrcStatanddestStatas the async path does.
if (identicalPaths(vol, srcPath, destPath))
throw cpCodeError('ERR_FS_CP_EINVAL', 'src and dest cannot be the same ' + destPath);
packages/fs-node/src/cp.ts:238
- Unlike
statOrNullused by async cp, this catch suppresses every destination stat error, includingEACCESandENOTDIR. The sync path then proceeds as if the destination were absent, potentially creating parents or reporting a later unrelated error; onlyENOENTshould be treated as missing.
let destStat: Stats | undefined;
try {
destStat = statOf(vol, dest, dereference);
} catch {}
packages/fs-node/src/cp.ts:348
- Recursive synchronous copies skip
checkPathsSyncfor children, unlike the async loop. Child type conflicts, self-equivalence, and special files are therefore not validated and can produceENOTDIRor silently skip entries instead of the corresponding cp errors; validate each child before copying it.
getStatsSync(vol, srcItem, destItem, options);
packages/fs-node/src/cp.ts:325
- When the destination directory already exists, the sync path merges into it without checking
errorOnExist/force, whileonDirthrows whenerrorOnExist && !force. This makescpSyncdisagree with the async APIs and ignoreerrorOnExistfor directory destinations.
if (!destStat) copyDirSync(vol, src, dest, options, true, Number(srcStat.mode));
else copyDirSync(vol, src, dest, options, false, undefined);
packages/fs-node/src/cp.ts:373
- For an existing non-symlink destination,
readlinkSyncreturnsEINVALand this branch callssymlinkSyncwithout removing the file, so the defaultforce: truebehavior fails withEEXISTinstead of replacing it. This path also does not apply theforce/errorOnExistpolicy used for regular files.
if ((error as ErrnoException).code === 'EINVAL') {
vol.symlinkSync(resolvedSrc, dest);
return;
packages/fs-node/src/cp.ts:553
- The async symlink path has the same overwrite bug: an existing regular destination reaches this
EINVALbranch andsymlinkSyncfails withEEXISTeven thoughforcedefaults to true. The link replacement must apply the same destination policy as file copies.
} catch (error) {
if ((error as ErrnoException).code === 'EINVAL') {
vol.symlinkSync(resolvedSrc, dest);
return;
packages/fs-node/src/cp.ts:408
checkPathsawaitsfilter, but the sharedICpOptions.filtertype still returns onlyboolean. Promise-based filters supported bypromises.cptherefore cannot be expressed by TypeScript callers; widen the public option type toboolean | Promise<boolean>while retaining the sync rejection for promise results.
const filter = options.filter;
if (filter && !(await filter(src, dest))) return undefined;
packages/fs-node/src/cp.ts:566
- This unconditional
statOf(..., true)makes copying a dangling source symlink fail withENOENTwhenever the destination is an existing symlink, even though dangling links are valid to copy withdereference: false. Use the existing non-throwing directory probe so the self-subdirectory check is skipped when the target does not exist.
if ((statOf(vol, src, true) as Stats).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc))
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Multiple moderate correctness issues remain in path resolution, overwrite handling, recursion, symlink behavior, and timestamp preservation.
Review details
Suppressed comments (17)
Previously missed (3) — in code that hasn't changed since the last review.
packages/fs-node/src/cp.ts:15
posix.resolveuses the host process cwd for relative paths, whileVolume/Superblockresolves relative virtual paths against its injectedprocess.cwd(). With a custom virtual cwd, relative cp paths can miss the self-subdirectory check, and relative symlink targets can be rewritten to a host path. Thread the volume's cwd through these path calculations instead of using the global resolver base.
packages/fs-node/src/cp.ts:103- Spreading
optionsover the defaults lets an explicitlyundefinedproperty overwrite its default, after whichvalidateBooleanrejects it. For example,{ recursive: undefined }used to behave like omitted options but now throws; resolve optional fields as omitted when their value isundefinedbefore validating.
packages/fs-node/src/cp.ts:404 - These lines unconditionally unlink and recreate an existing symlink, ignoring
forceanderrorOnExist. With{ force: false }, cp should leave an existing destination unchanged, and witherrorOnExist: trueit should report the existing-target error; this path overwrites it instead.
This issue also appears on line 591 of the same file.
packages/fs-node/src/tests/volume/cp-edge-cases.test.ts:23
- Adding
recursive: trueto this file-symlink case weakens the regression test: dereferencing a symlink to a regular file must not require the directory-only recursive option. Keep this call withoutrecursiveso it continues to exercise the normal file-copy path.
vol.cpSync('/link.txt', '/copy.txt', { dereference: true, recursive: true });
packages/fs-node/src/cp.ts:251
- Passing
!dereferencereverses the intendedstat/lstatchoice: with the defaultdereference: false, a symlink is followed here, so a dangling link fails and a link to a directory is treated as a directory; withdereference: true, the source islstated instead. This makes synchronouscpdiverge from the async path and Node behavior. Passdereferencedirectly.
srcStat = statOf(vol, src, !dereference);
packages/fs-node/src/cp.ts:369
- Recursive synchronous copies call
getStatsSyncdirectly for each child, bypassingcheckPathsSync. Nested type conflicts and self/alias checks are therefore skipped; for example, copying a source file over an existing destination directory withforce: falsesilently leaves the wrong destination instead of raising Node's type error. Run the same path checks for each entry, as the async traversal does.
if (filter && !filterSync(filter, srcItem, destItem)) continue;
getStatsSync(vol, srcItem, destItem, options);
packages/fs-node/src/cp.ts:314
- After directory traversal reaches a child, this function has no terminal branch for FIFO, socket, or other unknown types, so the synchronous implementation silently skips them.
checkPathsSynconly validates the top-level source, while the async implementation rejects these types for every item. Add the corresponding terminal errors here.
if (srcStat.isSymbolicLink()) return onLinkSync(destStat, vol, src, dest, options.verbatimSymlinks);
};
packages/fs-node/src/cp.ts:394
- When
destis an existing regular file,readlinkSyncthrowsEINVALand this branch attempts to create the replacement symlink without removing that file.symlinkSyncthen fails withEEXISTeven thoughforcedefaults to true, so a symlink cannot overwrite a regular destination. Remove the existing non-symlink destination after applying the force/errorOnExist policy.
if ((error as ErrnoException).code === 'EINVAL') {
vol.symlinkSync(resolvedSrc, dest);
return;
packages/fs-node/src/cp.ts:152
- The cleanup path unlinks
destwhenever the copy fails, butdestmay have existed beforecore.openbecause the open usesO_CREATfor both new and existing destinations. ACOPYFILE_FICLONE_FORCE/ENOSYSor source-directory failure can therefore delete a pre-existing destination instead of preserving it. Track whether the destination was newly created and only remove newly created outputs.
if (!copied)
try {
core.unlink(dest);
} catch {}
packages/fs-node/src/cp.ts:570
- The async symlink-copy path has the same overwrite bug: for an existing regular destination,
readlinkSyncreturnsEINVALand the code callssymlinkSyncwithout unlinking the destination, so the defaultforce: trueoperation fails withEEXIST. Remove the existing non-symlink destination after applying the overwrite policy.
if ((error as ErrnoException).code === 'EINVAL') {
vol.symlinkSync(resolvedSrc, dest);
return;
packages/fs-node/src/cp.ts:592
- The async path unconditionally unlinks and recreates an existing symlink, so it ignores
options.forceandoptions.errorOnExist.{ force: false }should leave an existing destination unchanged, whileerrorOnExistshould reject it; this branch overwrites it instead.
vol.unlinkSync(dest);
vol.symlinkSync(resolvedSrc, dest);
packages/fs-node/src/cp.ts:133
- When the source and destination resolve to the same inode,
copyFileshould fail withEINVAL(including for hard-link aliases), but this branch reports success. Throw the same-file error while keepingcopied = trueso the cleanup path does not unlink the source.
if (destNode === srcNode) {
copied = true;
return;
packages/fs-node/src/cp.ts:139
- These failure checks run after the destination has been opened and, for an existing file, truncated. A direct
copyFileSyncof a directory or withCOPYFILE_FICLONE_FORCEcan therefore destroy an existing destination before reporting the error, and thefinallyblock then unlinks it as well. Validate these conditions before opening the destination or only clean up a destination created by this call.
if (flags & COPYFILE_FICLONE_FORCE) throw createError(ERROR_CODE.ENOSYS, 'copyfile', src, dest);
if (srcNode.isDirectory()) throw createError(ERROR_CODE.EISDIR, 'copyfile', src, dest);
packages/fs-node/src/cp.ts:264
identicalPathsfollows both paths even whendereferenceis false. This makes copying a regular file over a symlink to that file, or one same-target symlink over another, fail asERR_FS_CP_EINVALinstead of treating the destination link as a separate entry; comparesrcStatanddestStatfor the non-dereferencing case, as the async path does.
if (identicalPaths(vol, srcPath, destPath))
packages/fs-node/src/cp.ts:583
- The second self-subdirectory guard stats
srcwithstatOf(..., true)instead of checking the resolved destination target, unlike the sync handler. Besides missing the destination-directory case, it throws for a dangling source symlink because the follow-stat fails; use the destination's resolved directory check here.
if ((statOf(vol, src, true) as Stats).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc))
packages/fs-node/src/cp.ts:334
copyFileCorecallssrcNode.getBuffer()while copying, andNode.getBuffer()updates the source atime. Re-stattingsrchere therefore captures the post-copy access time, sopreserveTimestampsdoes not preserve the original source timestamps; use thesrcStatalready captured before the copy.
if (options.preserveTimestamps) setDestTimestamps(vol, src, dest);
packages/fs-node/src/cp.ts:519
- As in the sync path,
copyFileCoreupdates the source atime before this helper re-stats it. The asyncpreserveTimestampsoption consequently copies the new access time rather than the pre-copy value held bysrcStat; apply the timestamps from that captured stat.
if (options.preserveTimestamps) setDestTimestamps(vol, src, dest);
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
No description provided.