Skip to content

node:fs: make cp/cpSync replace an existing destination file like node - #38226

Open
robobun wants to merge 1 commit into
mainfrom
farm/8a9fff0c/fs-cp-existing-dest-replace
Open

robobun wants to merge 1 commit into
mainfrom
farm/8a9fff0c/fs-cp-existing-dest-replace

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fs.cpSync(file, dest), fs.cp(file, dest, cb) and fs.promises.cp(file, dest) onto a dest that already exists as a regular file write the new contents into the existing inode. If dest is a hard link (bun install's Linux backend and pnpm both hard-link node_modules files out of their store), every other link to that inode now has the new contents too. node leaves the other links untouched; so does bun's own recursive cp(dir, dir) for the same file one level down.
  • Same cause, second symptom: onto a dest with mode 0444, all three forms fail with EACCES: permission denied, open '.../dest' when not running as root. node succeeds.
  • Cause: tryNativeFastPathSync (src/js/internal/fs/cp-sync.ts:308) and tryNativeFastPath (src/js/internal/fs/cp.ts:180) hand the copy to the native binding when src is a regular file and dest is missing or a regular file. The native copy (copy_single_file_sync, src/runtime/node/node_fs.rs:8551) opens dest with O_WRONLY | O_CREAT and clones / copy_file_ranges into whatever inode is there. node's mayCopyFile (and the ported walker's, cp-sync.ts:375) does unlink(dest) then copyFile, creating a new inode.

Fix

  • Take the native single-file copy only when dest does not exist, which is the condition the recursive fast path in the same functions already uses. An existing dest falls through to the ported walker, which unlinks it, copies and chmods exactly as node does. The stats collected by the gate are passed along, so the walker does not re-stat.
  • Correct because "dest exists" is precisely the case where rewriting in place is observable (other hard links, read-only mode, open file descriptors keep the old inode), and the walker is the node algorithm. The fast path still covers the copy-to-new-path case.
  • Verified:
    • test/js/node/fs/cp.test.ts: new hard-link tests for cpSync, promises.cp and callback cp, and read-only-dest tests for cpSync and promises.cp (skipped as root, where the mode is not enforced, and on Windows). Before the fix the hard-link tests fail with store: "new", nlink: 2; as a non-root user the read-only tests fail with EACCES. With the fix the file passes (50 pass, 7 skip on Linux), and all 5 new tests pass as a non-root user.
    • The 77 upstream test/js/node/test/parallel/test-fs-cp-* tests pass with the debug build.
    • The original repro below now prints the same results node v26.3.0 prints for the three single-file forms.

Background

  • fs.cp family dispatch (src/js/node/fs.ts cpSync, src/js/node/fs.promises.ts cp): when no option other than the default force: true is set, the JS side first runs node's validation (checkPaths, checkParentPaths) and then decides between the native binding and the JS port of node's lib/internal/fs/cp/ walker. The tryNativeFastPath* functions are that decision; they only say yes when the native result is indistinguishable from the walker's.
  • A hard link is a second directory entry for the same inode. Writing into the file through either name changes what both names show; unlinking one name and creating a new file under it leaves the other name pointing at the old, unchanged data. copyFileSync writes through in both node and bun; only cp has the unlink-first semantics.
Repro
import fs from "node:fs";
const d = fs.mkdtempSync("cp-hl-");
fs.writeFileSync(`${d}/store.txt`, "ORIGINAL\n");
fs.writeFileSync(`${d}/src.txt`, "NEW\n");
fs.linkSync(`${d}/store.txt`, `${d}/dst.txt`);
fs.cpSync(`${d}/src.txt`, `${d}/dst.txt`);
console.log(fs.readFileSync(`${d}/store.txt`, "utf8")); // bun 1.4.0: "NEW", node: "ORIGINAL"

Before (bun 1.4.0), the fuller script covering all forms:

cpSync(file, hardlinkedDest)           other-link=OVERWRITTEN
promises.cp(file, hardlinkedDest)      other-link=OVERWRITTEN
cp(file, hardlinkedDest, cb)           other-link=OVERWRITTEN
cpSync(dir,dir) td/x hardlinked        other-link=intact
promises.cp(dir,dir) td/x hardlinked   other-link=intact

After (this branch), and node v26.3.0 for the single-file forms:

cpSync(file, hardlinkedDest)           other-link=intact
promises.cp(file, hardlinkedDest)      other-link=intact
cp(file, hardlinkedDest, cb)           other-link=intact
cpSync(dir,dir) td/x hardlinked        other-link=intact
promises.cp(dir,dir) td/x hardlinked   other-link=intact

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/cp.test.ts

The native single-file fast path behind fs.cpSync, fs.cp and
fs.promises.cp was also taken when the destination already existed as
a regular file. It opens the existing file and rewrites it in place,
so when the destination is a hard link the new contents show up in
every other link to the inode, and a read-only destination fails with
EACCES. node unlinks the destination and creates a fresh file, which
is also what bun's own directory walker does for nested files.

Only take the native copy when the destination does not exist, the
same condition the recursive fast path already uses. An existing
destination goes through the ported walker (unlink, copyFile, chmod).
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dc150bef-5ce1-4446-8845-6ad63f0631a2

📥 Commits

Reviewing files that changed from the base of the PR and between b5afcac and aca0dff.

📒 Files selected for processing (3)
  • src/js/internal/fs/cp-sync.ts
  • src/js/internal/fs/cp.ts
  • test/js/node/fs/cp.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, CI running (Buildkite build 94902); the automated review found nothing to change and there are no open review threads.

Reproduced on bun 1.4.0 with the script in the PR description: fs.cpSync / fs.cp / fs.promises.cp of a file onto a hard-linked destination rewrote the shared inode (the other link read NEW, nlink stayed 2); as a non-root user the same calls onto a 0444 destination failed with EACCES. node v26.3.0 replaces the file in both cases.

With this branch the new tests in test/js/node/fs/cp.test.ts pass (they fail on main as described above), the file passes as root and as a non-root user, and the 77 upstream test-fs-cp-* tests pass against the debug build.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — narrows the single-file native fast-path gate to !destStat, so an existing dest falls through to the ported node walker's unlink-then-copy.

What was reviewed:

  • Traced both callers (fs.ts:979, fs.promises.ts:188) — when ok is false, checked is threaded to cpSyncFn/cpFn, which reaches mayCopyFile → unlink(dest) → copyFile → chmod(srcStat.mode), matching node.
  • Confirmed the change is symmetric across the sync/async twins and only tightens the gate; the recursive-directory branch already used the same !destStat condition.
  • Checked the read-only test's mode assertion: the walker chmods dest to srcStat.mode, so comparing against the source file's mode is the right invariant. skipIf on root/Windows is correct.
  • Hard-link tests assert store content, dest content, and nlink together — would fail with store: "new", nlink: 2 on the old code.
Extended reasoning...

Overview

Two-line production change: in tryNativeFastPathSync (src/js/internal/fs/cp-sync.ts) and tryNativeFastPath (src/js/internal/fs/cp.ts), the return condition tightens from srcStat.isFile() && (!destStat || destStat.isFile()) to srcStat.isFile() && !destStat. The accompanying comment is updated to explain why an existing regular-file dest must go through the ported walker (node unlinks first; the native binding rewrites the inode in place). Five new tests in test/js/node/fs/cp.test.ts cover the hard-link case (cpSync, promises.cp, callback cp) and the read-only-dest case (cpSync, promises.cp).

Security risks

None. This is a Node.js-compat behavioral fix in the fs.cp dispatch layer. The change only narrows when a native fast path is taken; the fallback is the existing node-ported walker already used for recursive copies, non-default options, and every other non-regular-file case. No new syscalls, no new user-controlled inputs, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-medium. The production diff is a single boolean-gate tightening applied identically to both sync/async twins — a strictly safer change (fast path taken in fewer cases, and the fallback is the reference implementation). I traced the callers in src/js/node/fs.ts and src/js/node/fs.promises.ts to confirm the {ok: false, checked} result is threaded into cpSyncFn/cpFn, which then reaches onFile → mayCopyFile (since destStat is set) → unlink(dest) + copyFile + chmod(srcStat.mode). That is exactly node's algorithm, and it's the same path the recursive walker already uses per-file when merging into an existing tree — so the "dir/dir already worked" observation in the PR description checks out.

Other factors

  • The recursive-directory branch in the same functions already gates on !destStat; this brings the single-file branch to parity.
  • Tests follow harness conventions: tempDir with await using, exact-value object assertions, callback form wired through Promise.withResolvers with the error path rejecting. The read-only test correctly skips as root (mode not enforced) and on Windows; its mode assertion compares against the source file's mode, which is what setDestMode writes.
  • The PR description states the whole cp.test.ts file and the 77 upstream test-fs-cp-* parallel tests pass on the debug build, and the before/after hard-link assertions demonstrate the tests would fail on the old code.
  • No CODEOWNERS entries cover these paths; no prior reviewer comments to address.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 PM PT - Aug 13th, 2026

❌ @robobun, your commit aca0dff has some failures in Build #94902 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38226

That installs a local version of the PR into your bun-38226 executable, so you can run:

bun-38226 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants