fs: create parent dirs when cpSync copies a symlink#28998
Conversation
|
Updated 5:15 AM PT - May 4th, 2026
❌ @robobun, your commit 9759b6d has some failures in 🧪 To try this PR locally: bunx bun-pr 28998That installs a local version of the PR into your bun-28998 --bun |
|
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: 📥 CommitsReviewing files that changed from the base of the PR and between 479c929847117a4e79ef75a8ea4f6e2c74c63783 and eee084f. 📒 Files selected for processing (2)
WalkthroughNative Zig Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
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 `@src/bun.js/node/node_fs.zig`:
- Line 6305: The code reintroduces std.fs.cwd() into Bun Zig files where mkdirp
retries call bun.makePath(std.fs.cwd(), ...); replace those usages with
bun.FD.cwd() so the cwd handle stays on Bun’s wrapper (e.g., change
bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) to use bun.FD.cwd());
apply the same replacement for the other matching occurrences that call
bun.makePath with std.fs.cwd() (also present in the other retry branches).
- Around line 6415-6422: The retry path currently calls Syscall.symlink(src,
dest) which copies the symlink path itself and breaks relative targets; instead,
when the first Syscall.symlink(src, dest) returns .err with NOENT, call
Syscall.readlink(src) (or equivalent) to obtain the link payload and then call
Syscall.symlink(targetPayload, dest) to recreate the same payload at the new
location; keep the existing bun.makePath(std.fs.cwd(), bun.path.dirname(dest,
.auto)) catch {} retry-before-return flow and mirror the regular-file fallback
behavior so relative links resolve correctly under a different parent.
🪄 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: 99fc4d16-9c7c-4fec-a101-c1e157f55408
📥 Commits
Reviewing files that changed from the base of the PR and between 1afabdd and 479c929847117a4e79ef75a8ea4f6e2c74c63783.
📒 Files selected for processing (2)
src/bun.js/node/node_fs.zigtest/regression/issue/28997.test.ts
| return ret.errnoSysP(c.copyfile(src, dest, null, mode_), .copyfile, src) orelse ret.success; | ||
| const first_try = ret.errnoSysP(c.copyfile(src, dest, null, mode_), .copyfile, src) orelse return ret.success; | ||
| if (first_try == .err and first_try.err.errno == @intFromEnum(Syscall.E.NOENT)) { | ||
| bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {}; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Use bun.FD.cwd() in these new mkdirp retries.
These branches reintroduce std.fs.cwd() into src/**/*.zig. Keep the cwd handle on Bun’s wrapper here as well.
♻️ Suggested change
- bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {};
+ bun.makePath(bun.FD.cwd().stdDir(), bun.path.dirname(dest, .auto)) catch {};
- bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {};
+ bun.makePath(bun.FD.cwd().stdDir(), bun.path.dirname(dest, .auto)) catch {};
- bun.makePathW(std.fs.cwd(), bun.path.dirnameW(dest)) catch {};
+ bun.makePathW(bun.FD.cwd().stdDir(), bun.path.dirnameW(dest)) catch {};As per coding guidelines: Use bun.FD.cwd() for current working directory.
Also applies to: 6419-6419, 6607-6607
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/bun.js/node/node_fs.zig` at line 6305, The code reintroduces std.fs.cwd()
into Bun Zig files where mkdirp retries call bun.makePath(std.fs.cwd(), ...);
replace those usages with bun.FD.cwd() so the cwd handle stays on Bun’s wrapper
(e.g., change bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) to use
bun.FD.cwd()); apply the same replacement for the other matching occurrences
that call bun.makePath with std.fs.cwd() (also present in the other retry
branches).
| const first_try = Syscall.symlink(src, dest); | ||
| if (first_try == .err and first_try.err.getErrno() == .NOENT) { | ||
| // Create the parent directory if it doesn't exist and retry, | ||
| // matching the regular-file fallback below. | ||
| bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {}; | ||
| return Syscall.symlink(src, dest); | ||
| } | ||
| return first_try; |
There was a problem hiding this comment.
Linux retry still recreates the wrong symlink target.
Syscall.symlink(src, dest) makes dest point at the source symlink path itself, not at the source symlink’s payload. Relative symlinks will still be copied incorrectly when the destination lives under a different parent. Read the link target first and retry symlink(target, dest) instead.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/bun.js/node/node_fs.zig` around lines 6415 - 6422, The retry path
currently calls Syscall.symlink(src, dest) which copies the symlink path itself
and breaks relative targets; instead, when the first Syscall.symlink(src, dest)
returns .err with NOENT, call Syscall.readlink(src) (or equivalent) to obtain
the link payload and then call Syscall.symlink(targetPayload, dest) to recreate
the same payload at the new location; keep the existing
bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {}
retry-before-return flow and mirror the regular-file fallback behavior so
relative links resolve correctly under a different parent.
| // The bug: symlink into a missing parent directory threw ENOENT. | ||
| // After the fix, the parent directory is created and the symlink is copied. | ||
| cpSync(join(root, "link.md"), join(root, "out2/link.md"), { recursive: true }); | ||
| expect(lstatSync(join(root, "out2/link.md")).isSymbolicLink()).toBe(true); | ||
| expect(readFileSync(join(root, "out2/link.md"), "utf8")).toBe("# Hello"); | ||
| }); |
There was a problem hiding this comment.
🟡 The tests verify that the copy is a symlink and reads correctly, but never call readlinkSync() to confirm the symlink's actual target matches the original. Because the tests use absolute symlink targets, a wrong copy where out2/link.md -> link.md -> target.md (a chain) instead of out2/link.md -> target.md (a direct copy of the target) would still pass both checks — the Linux ELOOP branch's bug (using src's path as the new link target rather than readlink(src)) is therefore undetectable by this test suite. Adding expect(readlinkSync(join(root, 'out2/link.md'))).toBe(readlinkSync(join(root, 'link.md'))) to both tests would close this gap.
Extended reasoning...
What the test verifies vs. what it should
The tests check two things: (1) lstatSync().isSymbolicLink() returns true, and (2) readFileSync() returns the expected file content. Neither check calls readlinkSync() to verify that the copied symlink points to the same target as the original symlink.
The specific code path this masks
On Linux, when open(src, O_NOFOLLOW) returns ELOOP (because src is a symlink), the ELOOP branch at ~L6412 calls Syscall.symlink(src, dest). This uses src's file path (e.g. /tmp/x/link.md) as the new link target, rather than what src actually points to (e.g. /tmp/x/target.md). The correct behavior — matching Node.js — is to call readlink(src) first and then create dest pointing to that result.
Why existing checks do not catch it
The test creates link.md -> /abs/path/target.md with an absolute path. The buggy copy creates out2/link.md -> /abs/path/link.md (a chain). isSymbolicLink() returns true — out2/link.md is indeed a symlink, just to the wrong target. readFileSync() follows the chain out2/link.md -> link.md -> target.md and returns '# Hello'. Both assertions pass despite the semantically incorrect copy.
Step-by-step proof
- symlinkSync('/tmp/x/target.md', '/tmp/x/link.md') — link.md points to /tmp/x/target.md
- cpSync('/tmp/x/link.md', '/tmp/x/out2/link.md') — Linux ELOOP branch runs Syscall.symlink('/tmp/x/link.md', '/tmp/x/out2/link.md')
- readlinkSync('/tmp/x/out2/link.md') returns '/tmp/x/link.md' (wrong — should be '/tmp/x/target.md')
- lstatSync('/tmp/x/out2/link.md').isSymbolicLink() -> true (passes incorrectly)
- readFileSync('/tmp/x/out2/link.md') follows the chain -> '# Hello' (passes incorrectly)
How to fix the test
Add after the readFileSync assertion in both tests:
expect(readlinkSync(join(root, 'out2/link.md'))).toBe(readlinkSync(join(root, 'link.md')));
This directly compares the symlink targets and would immediately expose the ELOOP branch wrong-target behavior if present.
Scope
This is a test quality issue, not a production-code defect introduced by this PR. The primary fix (ENOENT/parent dir creation) is correctly tested. The missing assertion provides false confidence that the Linux ELOOP path produces a semantically correct symlink copy.
| const first_try = ret.errnoSysP(c.copyfile(src, dest, null, mode_), .copyfile, src) orelse return ret.success; | ||
| if (first_try == .err and first_try.err.errno == @intFromEnum(Syscall.E.NOENT)) { | ||
| bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {}; | ||
| return ret.errnoSysP(c.copyfile(src, dest, null, mode_), .copyfile, src) orelse ret.success; | ||
| } | ||
| return first_try; |
There was a problem hiding this comment.
🟡 The three new symlink retry paths swallow makePath/makePathW errors with catch {}, so if directory creation fails (e.g., EACCES, EROFS), the caller receives a misleading ENOENT from the retry rather than the true cause. The Linux regular-file path at ~L6460 already handles this correctly by propagating the mkdirRecursive error.
Extended reasoning...
What the bug is: All three new symlink retry paths use bun.makePath(...) catch {} / bun.makePathW(...) catch {}, which silently discards any error from the directory-creation step. This affects the macOS symlink branch (L6305), the Linux ELOOP branch (L6419), and the Windows branch (L6607).
How it manifests: When makePath fails for a reason other than normal I/O (e.g., EACCES on the parent's parent, EROFS on a read-only filesystem), the discarded error means the destination directory is never created. The subsequent retry call (copyfile, symlink, or CreateSymbolicLinkW) then also fails — but with ENOENT (no directory), not with the actual root cause. The caller therefore receives a misleading error code.
Code path (Linux ELOOP example):
- First
open(src, O_NOFOLLOW)returns ELOOP → enter symlink branch. Syscall.symlink(src, dest)fails with ENOENT (destination dir missing).bun.makePath(...) catch {}is called but fails with EACCES — error discarded.Syscall.symlink(src, dest)is retried and fails again with ENOENT (dir still missing).- Caller receives ENOENT instead of EACCES.
Why existing code doesn't prevent it: The catch {} intentionally silences errors. On success, makePath returns nothing; on failure, the error value is thrown and immediately caught and discarded. There is no branch to inspect the failure cause.
Contrast with the Linux regular-file path (~L6460-6466): That path calls mkdirRecursive and explicitly checks the return value, propagating the mkdir error immediately if non-nil. This is the correct pattern and what should be followed here.
Addressing the refutation: One verifier noted that the macOS regular-file path (L6397, unchanged by this PR) also uses catch {}, so the new code follows the pre-existing macOS convention rather than introducing a new inconsistency. This is a valid point — the pattern is not new. However, the Linux symlink path now diverges within the same platform from the Linux regular-file path's better error handling, and the new Windows and macOS symlink paths could likewise be improved. Following a pre-existing weaker pattern does not make the pattern correct.
Impact: The operation still fails in all these scenarios — no data corruption or correctness issue. The only effect is that the error code reported to the user is misleading (ENOENT instead of EACCES/EROFS/EDQUOT), making debugging harder. Severity: nit.
Fix: Check makePath's result and return the mkdir error if it failed, matching the Linux regular-file pattern. For example on the Linux ELOOP branch:
if (bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto))) |_| {} else |mkdir_err| return .{ .err = mkdir_err };
return Syscall.symlink(src, dest);479c929 to
eee084f
Compare
…#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 #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>
eee084f to
4068bc0
Compare
There was a problem hiding this comment.
LGTM — each new ENOENT→makePath→retry block mirrors the adjacent regular-file fallback, and the earlier symlink-target concern is now moot since this sits on top of _cpSymlink.
Extended reasoning...
Overview
This PR adds an "ENOENT → create parent dir → retry" fallback to the four symlink branches of _copySingleFileSync in src/bun.js/node/node_fs.zig (macOS copyfile, Linux ELOOP, FreeBSD MLINK/LOOP, Windows CreateSymbolicLinkW), plus a regression test at test/regression/issue/28997.test.ts. Net change is ~30 lines of Zig across four small hunks.
What changed since my last review
On 2026-04-08 I flagged that the Linux ELOOP path called Syscall.symlink(src, dest) with the source path as the target, producing a chained link rather than a true copy. That pre-existing issue was fixed separately in #30073 (_cpSymlink, which does readlink(src) first), and this PR has been rebased to call _cpSymlink in both the Linux and FreeBSD branches. The remaining nits from my earlier comments (catch {} swallowing makePath errors, test not asserting readlinkSync) still apply but are minor and match the pre-existing pattern in the surrounding regular-file code.
Security risks
None. This is a localized retry-on-ENOENT for filesystem copy of a symlink; no auth, crypto, parsing, or untrusted-input handling. The makePath call uses dirname(dest) where dest is the user-supplied destination — identical to what the regular-file branches already do.
Level of scrutiny
Low-to-moderate. Each new block is a near-verbatim copy of the adjacent regular-file fallback in the same platform branch (macOS L6579-6584, Windows L6868-6875), so the pattern is already proven in production. I verified all four hunks against their neighboring regular-file equivalents and they match (same bun.makePath(std.fs.cwd(), ...) catch {} / bun.makePathW idiom, same errno check, same retry shape).
Other factors
- CodeRabbit's note about
std.fs.cwd()vsbun.FD.cwd()applies equally to the pre-existing regular-file fallbacks this PR copies from; not a regression introduced here. - Regression test covers both single-level and three-level-deep missing parents, gated against system Bun.
- Bug-hunting system found no issues on this revision.
cpSync's regular-file path already retries copyfile() after creating
the destination's parent directory when it fails with ENOENT. The
symlink branches in _copySingleFileSync did not, so
cpSync('link.md', 'out/link.md', { recursive: true })
threw ENOENT when out/ did not exist. Apply the same makePath+retry
fallback to the macOS copyfile, Linux _cpSymlink, FreeBSD _cpSymlink,
and Windows CreateSymbolicLinkW paths.
Fixes #28997
4068bc0 to
9759b6d
Compare
| return ret.errnoSysP(c.copyfile(src, dest, null, mode_), .copyfile, src) orelse ret.success; | ||
| const first_try = ret.errnoSysP(c.copyfile(src, dest, null, mode_), .copyfile, src) orelse return ret.success; | ||
| if (first_try == .err and first_try.err.errno == @intFromEnum(Syscall.E.NOENT)) { | ||
| bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {}; |
There was a problem hiding this comment.
🔴 These four new std.fs.cwd() calls push the std.fs.cwd ban-word count past its cap of 109 in test/internal/ban-limits.json, which is why test/internal/ban-words.test.ts is failing on every platform in Build #50563. Note that CodeRabbit's suggested replacement bun.FD.cwd().stdDir() won't help because .stdDir() is also banned — consider using this.mkdirRecursive(.{ .path = .{ .string = .init(bun.path.dirname(dest, .auto)) }, .recursive = true }) (as the Linux regular-file fallback at ~L6644 already does), or another bun.sys-based path that avoids std.fs entirely.
Extended reasoning...
What the bug is and how it manifests
The PR adds four new calls to std.fs.cwd() in src/runtime/node/node_fs.zig:
- L6489 — macOS symlink branch:
bun.makePath(std.fs.cwd(), bun.path.dirname(dest, .auto)) catch {}; - L6603 — Linux
ELOOPbranch: same call - L6761 — Linux
MLINK/LOOPbranch: same call - L6902 — Windows branch:
bun.makePathW(std.fs.cwd(), bun.path.dirnameW(dest)) catch {};
std.fs.cwd is a banned word in test/internal/ban-words.test.ts:44 (reason: "Prefer bun.FD.cwd()"), and test/internal/ban-limits.json:39 caps the repo-wide count at 109 instances. Adding 4 more pushes the count over the limit, so ban-words.test.ts fails with exit code 1.
The specific code path that triggers it
test/internal/ban-words.test.ts greps src/**/*.zig (excluding src/codegen, src/deps, src/unicode/uucode) for each banned phrase, compares the count against ban-limits.json, and throws if the count exceeds the recorded limit. Before this PR node_fs.zig contained 4 occurrences of std.fs.cwd(); after the PR it contains 8. The repo-wide count rises from ≤109 to ~113, exceeding the cap.
Why existing code doesn't prevent it
The only enforcement is the ban-words CI test itself, which is now failing. src/CLAUDE.md documents the same guideline ("std.fs.cwd() → bun.FD.cwd()") but is advisory only. CodeRabbit flagged this in an inline comment, but its suggested replacement bun.FD.cwd().stdDir() is itself banned (ban-words.test.ts:48: ".stdDir()" → "Prefer bun.FD or bun.sys, or convince Jarred why this exception is necessary"), so applying that suggestion would just trade one CI failure for another.
Step-by-step proof
test/internal/ban-limits.json:39→"std.fs.cwd": 109.git grep -c 'std\.fs\.cwd' -- 'src/**/*.zig'on the base commit (excludingsrc/codegen,src/deps,src/unicode/uucode) → ≤109.- PR diff adds exactly 4 new
std.fs.cwd()literals and removes none;ban-limits.jsonis not bumped. - Post-PR count → ≥113 > 109.
ban-words.test.tsthrows"std.fs.cwd" is banned … found N > 109 instances→ exit code 1.- robobun's CI report for Build #50563 confirms
test/internal/ban-words.test.tsfailing with code 1 on all 15 platforms (Linux x64/aarch64/asan/baseline, Alpine, macOS x64/aarch64, Windows x64/aarch64).
Impact
Hard CI break on every platform — blocks merge.
How to fix
Avoid std.fs entirely. The Linux regular-file fallback in this same function (~L6644) already shows the preferred pattern:
this.mkdirRecursive(.{
.path = .{ .string = bun.PathString.init(bun.path.dirname(dest, .auto)) },
.recursive = true,
});Using this.mkdirRecursive(...) for the macOS/Linux branches and the equivalent bun.sys path for Windows removes the std.fs.cwd() calls (and, as a bonus, also addresses the "swallowed mkdir error" nit raised separately). Alternatively bun.FD.cwd().asDir() could be used if bun.makePath must be kept, but switching to the existing mkdirRecursive helper is the cleaner option since it's already used a few lines below for the same purpose. Bumping ban-limits.json would also make CI green but is discouraged — the codebase is actively migrating away from std.fs.
| // https://github.com/oven-sh/bun/issues/28997 | ||
| // | ||
| // cpSync should create missing parent directories for symlinks, matching its | ||
| // behavior for regular files and matching Node.js. | ||
| import { expect, test } from "bun:test"; | ||
| import { cpSync, lstatSync, readFileSync, symlinkSync } from "fs"; | ||
| import { tempDir } from "harness"; | ||
| import { join } from "path"; |
There was a problem hiding this comment.
🟡 nit: per test/CLAUDE.md, test/regression/issue/ is reserved for true regressions (worked in a prior release, then broke). Since the symlink branches never had the ENOENT→makePath retry, this was never-correct rather than regressed — these two cases would conventionally live in test/js/node/fs/cp.test.ts instead. Purely organizational, no functional impact.
Extended reasoning...
What the issue is
Both CLAUDE.md (line ~66) and test/CLAUDE.md (line ~153) document that test/regression/issue/${issueNumber}.test.ts is reserved "only for bugs that have a GitHub issue number and are true regressions (worked in a previous release, then broke). An issue number alone does not qualify — if it was never correct, put the test in the module's existing test file instead."
This PR adds test/regression/issue/28997.test.ts, but per the PR description's own "Cause" section, the symlink branches in _copySingleFileSync simply never had the ENOENT→makePath retry that the regular-file branch had. That is missing-implementation behavior — it was never correct in any prior release — rather than something that worked and later regressed.
Why the convention says it goes elsewhere
The convention exists so that test/regression/ stays a focused catalog of "this used to work, then broke" cases, while feature/correctness tests accumulate in the relevant module suite where they're discoverable alongside related coverage. test/js/node/fs/cp.test.ts already exists and is the natural home for cpSync symlink coverage.
Step-by-step
- The guideline:
test/CLAUDE.md:153— "only for … true regressions … if it was never correct, put the test in the module's existing test file instead." - The PR description states the macOS/Linux/Windows symlink branches lacked the retry that the regular-file branch had — i.e., never implemented.
- Therefore cpSync fails with ENOENT when copying symlinks into non-existent directories #28997 is a never-correct bug, not a regression.
- Per (1), the two test cases belong in
test/js/node/fs/cp.test.ts, optionally with a// https://github.com/oven-sh/bun/issues/28997comment for traceability.
Impact
Zero functional impact — the test runs and passes correctly where it is, and is discoverable via the issue number. There are also ~400 files already in test/regression/issue/, so historical enforcement of this convention is loose. This is purely an organizational nit and should not block the PR.
How to fix
Move the two test(...) blocks into test/js/node/fs/cp.test.ts (keeping the issue-URL comment) and delete test/regression/issue/28997.test.ts.
…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
cpSync's regular-file path already retriescopyfile()after creating the destination's parent directory when it fails withENOENT. The symlink branches in_copySingleFileSyncdid not, sothrew
ENOENT: no such file or directory, symlinkwhenout/did not exist. Regular files in the same scenario worked fine, and Node and Bun's JS fallback (used withdereference: true) both create the parent directory first.Repro
Cause
src/bun.js/node/node_fs.zig::_copySingleFileSync:copyfile(src, dest, COPYFILE_NOFOLLOW_SRC)and returned the result directly. The regular-file branch a few lines below doesmakePath+ retry onENOENT; the symlink branch didn't.open(src, O_NOFOLLOW)returnedELOOP, the code calledSyscall.symlink(src, dest)and returned unchanged. The regular-file path right below catchesENOENTonopen(dest, …)and runsmkdirRecursivebefore retrying; the symlink fallback didn't.CreateSymbolicLinkWbranch (~L6591): same pattern — any failure propagated, includingPATH_NOT_FOUND.Fix
Apply the existing "ENOENT → makePath → retry" fallback to all three symlink paths, mirroring the regular-file code in each platform branch.
Test
test/regression/issue/28997.test.ts— creates a symlink and runscpSyncwith a missing parent dir (one-level and three-level deep). Uses absolute symlink targets so the test passes on every platform.Fixes #28997