node:fs: apply cpSync errorOnExist per entry, not to the destination directory - #33416
node:fs: apply cpSync errorOnExist per entry, not to the destination directory#33416robobun wants to merge 3 commits into
Conversation
…directory
fs.cpSync(src, dest, { recursive: true, force: false, errorOnExist: true })
threw ERR_FS_CP_EEXIST as soon as dest itself existed, so the merge-but-never-
overwrite idiom could never run against a destination that had been created.
node's cp-sync.js onDir has no such check: it merges into an existing directory
and raises ERR_FS_CP_EEXIST only from mayCopyFile, on a colliding file. Its
async cp.js onDir does refuse the directory, which is why internal/fs/cp.ts
keeps that branch.
|
Warning Review limit reached
Next review available in: 14 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Updated 3:30 AM PT - Jul 6th, 2026
❌ @robobun, your commit aeb489e has some failures in 🧪 To try this PR locally: bunx bun-pr 33416That installs a local version of the PR into your bun-33416 --bun |
There was a problem hiding this comment.
LGTM — clean removal of the over-strict directory check to match node's cpSync, well-covered by the three new tests; the inline nit about the missing mirror comment on cp.ts is non-blocking.
Extended reasoning...
Overview
This PR removes an 8-line errorOnExist && !force check from onDir in src/js/internal/fs/cp-sync.ts and adds a 3-line explanatory comment in its place. The removed check caused fs.cpSync to throw ERR_FS_CP_EEXIST on the destination directory itself, whereas node's cpSync merges into an existing directory and only errors per colliding file (via mayCopyFile, which is unchanged). The async cp.ts intentionally keeps its directory-level check because node's async cp really is stricter — the PR description documents this asymmetry with a differential table against node v26.3.0. Three new tests in test/js/node/fs/cp.test.ts pin all three behaviors: sync merges, sync still errors on a colliding file, and async refuses the directory.
Security risks
None. This loosens an over-strict check to match node, but the per-file collision guard in mayCopyFile remains — errorOnExist still prevents overwriting existing files. No path traversal, permissions, or untrusted-input handling is affected.
Level of scrutiny
Low-to-moderate. This is a small, targeted Node.js compatibility fix in a JS built-in module — a pure deletion bringing the ported code back in line with upstream lib/internal/fs/cp/cp-sync.js. The PR description traces the bug's origin (#31830 copied the async onDir body into the sync path), verifies against node's own behavior across 7 scenarios, and confirms all 77 test-fs-cp-* node parallel tests pass. The change is mechanical and easily audited against node's source.
Other factors
The one finding is a nit: the PR description says a comment was added "on both halves" but only cp-sync.ts was touched — cp.ts's onDir has no mirror comment. This is a documentation gap, not a correctness issue: the sync-side comment already explains the asymmetry ("node's async cp refuses the directory itself; its cpSync does not"), and the new promises.cp refuses the destination directory test pins the async behavior against future regression. The nit is worth addressing but doesn't block approval.
There was a problem hiding this comment.
LGTM — the mirror comment on the async onDir addresses my earlier nit; small, well-tested Node compat fix.
Extended reasoning...
Overview
Removes the directory-level errorOnExist && !force throw from cpSync's onDir in src/js/internal/fs/cp-sync.ts so that an existing destination directory is merged into (with ERR_FS_CP_EEXIST raised only per colliding file), matching Node's lib/internal/fs/cp/cp-sync.js. The async onDir in src/js/internal/fs/cp.ts keeps the check (Node's async cp really is stricter), and both functions now carry a comment explaining the intentional asymmetry. Three new tests in test/js/node/fs/cp.test.ts pin: cpSync merges into an existing dir, cpSync still throws on a colliding file (with the file's path on the error), and promises.cp refuses the existing directory outright.
Security risks
None. This is a Node.js compatibility fix in the JS-side fs.cp walker. It relaxes one over-strict check (refusing an existing destination directory) to match Node's documented and observed behavior; the per-file collision check in mayCopyFile is unchanged, so errorOnExist still prevents overwriting existing files. No auth, crypto, path-traversal, or untrusted-input parsing is touched.
Level of scrutiny
Low-to-moderate. The functional change is a 9-line deletion whose correctness is directly verifiable against Node's reference implementation (the PR description includes a 7-row differential table against Node v26.3.0), plus two 3-line comments. The rest is test coverage. The blast radius is limited to fs.cpSync(..., { recursive: true, force: false, errorOnExist: true }) on an existing destination directory — a case that previously always failed in Bun, so this can only make previously-failing calls succeed the way Node does.
Other factors
- My earlier inline nit (only
cp-sync.tshad the explanatory comment) has been addressed in commit 30c75b0, which adds the mirror comment tocp.ts'sonDir. - No bugs were found by the bug-hunting system on the current revision.
- The PR reports 50/50 tests passing in
cp.test.tsand all 77test-fs-cp-*Node parallel tests passing, including the two that specifically cover this sync/async asymmetry. - Tests are placed in the existing
test/js/node/fs/cp.test.tsalongside related coverage, usetempDirWithFiles, and assert exact error codes and paths.
There was a problem hiding this comment.
LGTM — the mirror comment on the async side is in now, and the fix + tests match node's cpSync/cp split cleanly.
Extended reasoning...
Overview
The runtime change is a 9-line deletion in src/js/internal/fs/cp-sync.ts: the errorOnExist && !force branch in onDir() is removed so cpSync merges into an existing destination directory and only raises ERR_FS_CP_EEXIST per colliding file (via the existing mayCopyFile). A 3-line comment is added there, and — following my earlier nit — a mirrored 3-line comment is added above the async onDir() in src/js/internal/fs/cp.ts, which intentionally keeps the directory-level check. Three tests are added to test/js/node/fs/cp.test.ts pinning (a) sync merge into an existing dir, (b) sync per-file EEXIST on a nested collision, and (c) async promises.cp still refusing the directory.
Security risks
None. This is a Node.js-compat semantics fix in JS-side ported code; it changes when an error is thrown, not what is written or where. No path handling, permissions, or trust boundaries change.
Level of scrutiny
Low. The touched code is a near-verbatim port of node's lib/internal/fs/cp/cp-sync.js, and the deleted branch is demonstrably absent from that upstream file — it was accidentally copied over from node's async cp.js in a prior PR. The description includes a differential table against node v26.3.0 across seven scenarios and confirms the 77 ported test-fs-cp-* tests still pass. The async side is unchanged in behavior (comment only).
Other factors
My previous review flagged only the missing async-side comment; that was addressed in 30c75b0 and the thread is resolved. The new tests are well-placed, use the harness helpers correctly, assert exact directory contents and error .code/.path, and lock in the intentional sync/async divergence so it can't be "fixed" by mistake later. No CODEOWNERS cover these paths.
Status: diff is ready, CI is red on a darwin-aarch64 infra failureThe change itself is green. The only hard CI failure is a Buildkite artifact download timing out on the macOS aarch64 agent, which kills the job before a single test runs: Zero test failures in that job, because no tests executed. This is not specific to this PRThe same Across the two runs of this branch, nothing red ever touched
Everything else was already inside Buildkite's own Why the diff cannot be responsibleThis PR changes three files, all It also cannot affect macOS's if (!filter && !dereference && !preserveTimestamps && !verbatimSymlinks && !mode && !errorOnExist && force) {
const { ok, checked } = tryNativeFastPathSync(src, dest, options);With Verification
I have used my one re-roll and am not going to keep pushing empty commits. This needs a maintainer to retry the darwin lane or merge through it. |
Repro
force: false+errorOnExist: trueis the "add my files, never clobber the user's" mode, and a destination directory essentially always exists, so every such call fails.Cause
onDirinsrc/js/internal/fs/cp-sync.tsrefused the destination directory itself:node's
lib/internal/fs/cp/cp-sync.jshas no such check:onDirmerges, andERR_FS_CP_EEXISTcomes only frommayCopyFile, per colliding file. The branch above is the body of node's asynclib/internal/fs/cp/cp.jsonDir, which does refuse the directory. It was copied into the sync path in #31830, where only the async half belonged.Fix
Drop the check from the sync
onDir.src/js/internal/fs/cp.ts(async) keeps it, because node's asynccpreally is stricter than itscpSync. Added a comment on both halves so the asymmetry doesn't read as an oversight.Verification
Differential run against node v26.3.0,
{ recursive: true, force: false, errorOnExist: true }:cpSynccpSyncbeforecpSyncafterfs.promises.cp/fs.cpmatch node on every row both before and after (all seven refuse the existing directory), and a test now pins that.Bun's thrown error is a
SystemErrorcarryingpath= the colliding file. node setspathonly when afilteris passed (without one itscopyDirruns in C++ and drops the field); thecodeand message text are identical either way.bun bd test test/js/node/fs/cp.test.ts— 50 pass, 0 fail (3 new)test/js/node/test/parallel/test-fs-cp-*pass, includingtest-fs-cp-async-dir-exists-error-on-exist.mjsandtest-fs-cp-sync-error-on-exist.mjs