fs.cp: preserve symlink target instead of linking back to source path#30073
Conversation
When _copySingleFileSync on Linux/FreeBSD hit ELOOP from open(src, O_NOFOLLOW), it fell back to Syscall.symlink(src, dest), creating a link at dest whose target string was the path of the source symlink rather than what that symlink pointed at. Every symlink copied by fs.cp/fs.cpSync therefore pointed back into the source directory, and removing the source left them dangling. Read the source link's target with readlink() first and, matching Node's default (non-verbatimSymlinks) behaviour, resolve a relative target against dirname(src) before creating the new link. Also accept EMLINK in the FreeBSD branch since FreeBSD's open(2) returns that for O_NOFOLLOW on a symlink.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughAdds ChangesSymlink Copy Behavior
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Review rate limit: 4/5 reviews remaining, refill in 12 minutes. Comment |
|
Updated 7:18 AM PT - May 2nd, 2026
❌ @robobun, your commit e915d32 has 2 failures in
Details🧪 To try this PR locally: bunx bun-pr 30073That installs a local version of the PR into your bun-30073 --bun |
…ve link targets in the test Use joinAbsStringBufChecked for the dirname(src)+target resolve so a near-PATH_MAX relative symlink target surfaces ENAMETOOLONG instead of overflowing resolved_buf. Extend the cp.test.ts case to exercise both the isAbsolute early return and the relative-target resolve branch.
There was a problem hiding this comment.
Thanks — the bounds-checked join and the relative-target test coverage both look right now, and I have nothing further; leaving final sign-off to a human since this is new path-resolution logic in the native fs layer (and it'll need coordinating with #28998).
Extended reasoning...
Overview
This PR fixes fs.cp/fs.cpSync on Linux/FreeBSD where the O_NOFOLLOW → ELOOP fallback created symlinks pointing back at the source link path instead of the source link's target. The fix adds a ~50-line _cpSymlink helper in src/bun.js/node/node_fs.zig that readlinks the source, resolves relative targets against dirname(src), and creates the destination link. Two callsites in _copySingleFileSync are updated (Linux + FreeBSD branches, the latter now also accepting EMLINK). A new test in test/js/node/fs/cp.test.ts covers both absolute- and relative-target symlinks for both cpSync and fs.promises.cp.
What changed since my last review
Commit 374190d directly addresses the two issues I raised on the previous revision:
- 🔴 stack overflow —
joinAbsStringBufZreplaced withjoinAbsStringBufChecked, passingresolved_buf[0..len-1]to reserve a byte for the manually-written NUL sentinel, and returningENAMETOOLONG(with the source path attached) on overflow. Verified againstresolve_path.zig:1337andsys.zig:2575— types line up and the sentinel write is in bounds. - 🟡 relative-target coverage — the test now creates both
abs_link(absolute target) andrel_link(../target.txt) and assertsrealpathSyncmatches for each, exercising both branches of_cpSymlink.
The 🟣 pre-existing EEXIST/force:true gap remains, but was explicitly flagged as out of scope and is not a regression.
Security risks
Symlink handling in a recursive copy is security-adjacent (path traversal, link spoofing), but fs.cp is already a fully-trusted local-fs operation — a caller who can invoke it can invoke fs.symlink directly. The change strictly reads the existing link target and recreates it at the destination, which is what Node does. The earlier user-controlled-length overflow risk is now closed with the checked join. I don't see new security exposure.
Level of scrutiny
Moderate-to-high. This is hand-written Zig in the native node:fs implementation with manual buffer management and platform-specific errno handling, and the PR description notes it overlaps with #28998 on the same lines. It is a focused, well-tested bug fix and I believe it's correct, but it's not a mechanical change — a human reviewer familiar with node_fs.zig should confirm the getcwd fallback semantics and the FreeBSD EMLINK addition.
Other factors
- Bug-hunter found no issues on this revision.
- CI failures on the prior commit (
no-orphans.test.tson macOS,dev-and-prod.test.tson Windows aarch64) are unrelated to this Linux/FreeBSD-only code path. - Test suite for
cp.test.tspasses per the PR description;zig:check-allis green.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Write a test that uses node:test and can run in both Node.js and Bun. Verify the tests pass.
robobun
left a comment
There was a problem hiding this comment.
Added in cc2a49c — test/js/node/fs/cp-symlink-target.test.ts uses node:test + node:assert only.
$ node --experimental-strip-types --test test/js/node/fs/cp-symlink-target.test.ts
▶ fs.cp preserves symlink targets instead of linking back to the source path
✔ fs.cpSync (2.0ms)
✔ fs.promises.cp (6.7ms)
ℹ pass 2 / fail 0
$ bun bd test test/js/node/fs/cp-symlink-target.test.ts
(pass) ... > fs.cpSync
(pass) ... > fs.promises.cp
2 pass / 0 fail
$ USE_SYSTEM_BUN=1 bun test test/js/node/fs/cp-symlink-target.test.ts
(fail) ... > fs.cpSync # readlink returned the source symlink path
(fail) ... > fs.promises.cp
0 pass / 2 fail
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/js/node/fs/cp-symlink-target.test.ts`:
- Around line 1-3: Update the header comment that currently says "This test runs
under `bun test`..." to reference the correct run command "bun bd test <file>"
instead; locate the top-of-file comment string in cp-symlink-target.test.ts and
replace the instance of "`bun test`" with "`bun bd test <...test file>`" (or
"`bun bd test <file>`") so it conforms to the repository guideline for test
commands.
- Around line 76-96: Move the describe block "fs.cp preserves symlink targets
instead of linking back to the source path" (including the contained tests
"fs.cpSync" and "fs.promises.cp") out of
test/js/node/fs/cp-symlink-target.test.ts and add it into the existing
test/js/node/fs/cp.test.ts alongside related cp tests; ensure the tests still
call makeFixture() and check(...) and use the same imports (fs, fsp, path) and
the same cleanup logic (fs.rmSync(...)) in their finally blocks; after moving,
remove the now-empty cp-symlink-target.test.ts file so new coverage lives in the
existing cp.test.ts per test guidelines.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f0edbceb-c20a-4f44-9d44-a7da35a20ab9
📒 Files selected for processing (1)
test/js/node/fs/cp-symlink-target.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@test/js/node/fs/cp-symlink-target.test.ts`:
- Around line 21-23: Replace manual mkdtempSync usage by creating the test
workspace with harness.tempDir: update makeFixture to accept a base directory
parameter (e.g., makeFixture(base)) instead of calling
fs.mkdtempSync/fs.realpathSync internally, import tempDir from "harness" in the
test, and in the test body create the temp dir via “using base =
tempDir('cp-symlink-')” then call makeFixture(base) to get
origTarget/srcAbs/srcRel; keep fs.cpSync(...) and check(...) unchanged. Ensure
all references to fs.mkdtempSync and os.tmpdir() are removed and the test
follows the existing pattern (test "fs.cpSync", makeFixture, check).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6287bd1e-3854-470a-a9ba-238fdbf13c58
📒 Files selected for processing (1)
test/js/node/fs/cp-symlink-target.test.ts
There was a problem hiding this comment.
No new issues found — the buffer-overflow and test-coverage points from the earlier pass were addressed in 374190d. Deferring to a human since this is hand-rolled path/buffer logic in the native fs.cp symlink path, and the last CI report (cc2a49c) was still red across build-zig targets.
Extended reasoning...
Overview
This PR adds NodeFS._cpSymlink (~50 lines of Zig in src/bun.js/node/node_fs.zig) to fix fs.cp/fs.cpSync on Linux/FreeBSD so copied symlinks point at the original target rather than back at the source link path. It wires the helper into the ELOOP/EMLINK fallback in _copySingleFileSync and adds tests in cp.test.ts plus a dual-runtime cp-symlink-target.test.ts.
Security risks
Symlink handling and path resolution are classically security-adjacent (traversal, buffer bounds). The first revision had a reachable stack overflow via joinAbsStringBufZ on long relative targets, now fixed with joinAbsStringBufChecked + ENAMETOOLONG. The current code looks bounds-safe (sentinel byte reserved, error path copies into sync_error_buf), but the fact that one memory-safety issue surfaced during review argues for human eyes on the final state.
Level of scrutiny
High. This is production-critical native runtime code on a hot Node-compat path, with manual stack PathBuffer management, a behavioural choice about how relative targets are resolved (against dirname(src) to match Node's non-verbatimSymlinks default), and a noted overlap with #28998 that whichever lands second will need to rebase around.
Other factors
All prior inline comments (mine and CodeRabbit's) are resolved. The CodeRabbit style suggestions on the new test file were correctly declined for dual-runtime portability. The robobun build-status comment still shows widespread build-zig failures at cc2a49c; the only commit since is an autofix, so CI status on the current head is unconfirmed. Given the surface area and CI uncertainty, a maintainer should sign off rather than auto-approving.
GetFinalPathNameByHandleW returns the filesystem's canonical case (C:\Windows\Temp) while paths built from os.tmpdir() carry the environment's case (C:\Windows\TEMP).
There was a problem hiding this comment.
The buffer-overflow and test-coverage concerns from the earlier round are addressed and I found nothing new, but this is non-trivial native path-resolution logic in node_fs.zig (and overlaps with #28998), so it's worth a human sign-off.
Extended reasoning...
Overview
Adds NodeFS._cpSymlink (~50 lines of Zig) that readlinks the source link, resolves relative targets against dirname(src) via joinAbsStringBufChecked, and recreates the link at dest. Wires it into the Linux .LOOP and FreeBSD .MLINK/.LOOP fallbacks in _copySingleFileSync. Adds coverage in cp.test.ts (abs + rel targets) and a new dual-runtime cp-symlink-target.test.ts runnable under node --test.
Security risks
No new attack surface beyond what fs.cp already exposes — the change makes copied links point at the same thing the source link pointed at (Node's behaviour) instead of back into the source tree. The earlier stack-overflow risk from unchecked joinAbsStringBufZ was fixed in 374190d by switching to joinAbsStringBufChecked with a reserved sentinel byte and ENAMETOOLONG on overflow.
Level of scrutiny
Medium-high. This is production native code in a core node:fs path with manual buffer management and path arithmetic across Linux/FreeBSD. The relative-target resolution (cwd + dirname(src) + target) is the kind of logic where off-by-ones or platform quirks surface later, and the first review pass already caught a real memory-safety bug. Not a mechanical change.
Other factors
All prior inline comments (mine and CodeRabbit's) are resolved. The pre-existing EEXIST/force:true gap was correctly scoped out as a follow-up. The PR description notes line overlap with #28998, so whoever merges should be aware a rebase is needed for whichever lands second. Tests cover both branches and pass per the description; zig:check-all passes.
…oven-sh#30073) ## What `fs.cp` / `fs.cpSync` on Linux and FreeBSD copied every symlink as a link whose target was the *path of the source symlink* rather than what that symlink pointed at. Deleting the source directory left every copied link dangling. ## Repro ```js import fs from 'fs'; import { join } from 'path'; fs.mkdirSync('from', { recursive: true }); fs.writeFileSync('target.txt', 'hello'); fs.symlinkSync(join(process.cwd(), 'target.txt'), 'from/link'); fs.cpSync('from', 'to', { recursive: true }); console.log(fs.readlinkSync('to/link')); // Bun (before): "<cwd>/from/link" ← points back at the source symlink // Node: "<cwd>/target.txt" ← points at the original target ``` ## Cause `_copySingleFileSync` (src/bun.js/node/node_fs.zig) opens the source with `O_NOFOLLOW`. When the source is a symlink, that fails with `ELOOP` and the fallback did: ```zig return Syscall.symlink(src, dest); ``` `symlink(target, linkpath)` takes the *target string* first, so this created `dest` with a target of `src` — the source symlink's path — instead of what the source symlink pointed at. ## Fix Added `_cpSymlink(src, dest)`, called from both the Linux and FreeBSD branches: 1. `readlink(src)` to get the source link's target 2. If the target is relative, resolve it against `dirname(src)` (matching Node's default / non-`verbatimSymlinks` behaviour — the native path is only taken when that option is unset) 3. `symlink(target, dest)` Also accept `EMLINK` in the FreeBSD branch since FreeBSD's `open(2)` returns that (not `ELOOP`) for `O_NOFOLLOW` on a symlink. ## Verification New test in `test/js/node/fs/cp.test.ts` (`symlinks - copied link target is the original target, not the source link path`) for both `cpSync` and `fs.promises.cp`: - asserts `readlinkSync(copied) !== srcLinkPath` - asserts the copied link resolves to the original target - deletes the source tree and asserts the copied link still reads ``` USE_SYSTEM_BUN=1 bun test test/js/node/fs/cp.test.ts -t 'copied link target' → 2 fail bun bd test test/js/node/fs/cp.test.ts -t 'copied link target' → 2 pass bun bd test test/js/node/fs/cp.test.ts → 39 pass / 2 skip bun run zig:check-all → all targets OK ``` Touches the same lines as oven-sh#28998 (which adds a parent-dir retry around this symlink call) — whichever lands second will need a trivial rebase. --------- Co-authored-by: robobun <robobun@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
What
fs.cp/fs.cpSyncon Linux and FreeBSD copied every symlink as a link whose target was the path of the source symlink rather than what that symlink pointed at. Deleting the source directory left every copied link dangling.Repro
Cause
_copySingleFileSync(src/bun.js/node/node_fs.zig) opens the source withO_NOFOLLOW. When the source is a symlink, that fails withELOOPand the fallback did:symlink(target, linkpath)takes the target string first, so this createddestwith a target ofsrc— the source symlink's path — instead of what the source symlink pointed at.Fix
Added
_cpSymlink(src, dest), called from both the Linux and FreeBSD branches:readlink(src)to get the source link's targetdirname(src)(matching Node's default / non-verbatimSymlinksbehaviour — the native path is only taken when that option is unset)symlink(target, dest)Also accept
EMLINKin the FreeBSD branch since FreeBSD'sopen(2)returns that (notELOOP) forO_NOFOLLOWon a symlink.Verification
New test in
test/js/node/fs/cp.test.ts(symlinks - copied link target is the original target, not the source link path) for bothcpSyncandfs.promises.cp:readlinkSync(copied) !== srcLinkPathTouches the same lines as #28998 (which adds a parent-dir retry around this symlink call) — whichever lands second will need a trivial rebase.