Skip to content

fs: create parent dirs when cpSync copies a symlink#28998

Open
robobun wants to merge 1 commit into
mainfrom
farm/ae1fb9fd/cpsync-symlink-mkdir
Open

fs: create parent dirs when cpSync copies a symlink#28998
robobun wants to merge 1 commit into
mainfrom
farm/ae1fb9fd/cpsync-symlink-mkdir

Conversation

@robobun

@robobun robobun commented Apr 8, 2026

Copy link
Copy Markdown
Collaborator

What

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: no such file or directory, symlink when out/ did not exist. Regular files in the same scenario worked fine, and Node and Bun's JS fallback (used with dereference: true) both create the parent directory first.

Repro

const { cpSync, symlinkSync, writeFileSync, mkdirSync } = require('fs');
mkdirSync('/tmp/x', { recursive: true });
process.chdir('/tmp/x');
writeFileSync('target.md', '# Hello');
symlinkSync('target.md', 'link.md');

cpSync('target.md', 'out/target.md', { recursive: true });   // ok
cpSync('link.md', 'out2/link.md', { recursive: true });      // ENOENT

Cause

src/bun.js/node/node_fs.zig::_copySingleFileSync:

  • macOS symlink branch (~L6296): called copyfile(src, dest, COPYFILE_NOFOLLOW_SRC) and returned the result directly. The regular-file branch a few lines below does makePath + retry on ENOENT; the symlink branch didn't.
  • Linux ELOOP fallback (~L6410): when open(src, O_NOFOLLOW) returned ELOOP, the code called Syscall.symlink(src, dest) and returned unchanged. The regular-file path right below catches ENOENT on open(dest, …) and runs mkdirRecursive before retrying; the symlink fallback didn't.
  • Windows CreateSymbolicLinkW branch (~L6591): same pattern — any failure propagated, including PATH_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 runs cpSync with a missing parent dir (one-level and three-level deep). Uses absolute symlink targets so the test passes on every platform.

# gate verification
USE_SYSTEM_BUN=1 bun test test/regression/issue/28997.test.ts  → 0 pass / 2 fail (ENOENT)
bun bd test test/regression/issue/28997.test.ts                → 2 pass / 0 fail

Fixes #28997

@robobun

robobun commented Apr 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:15 AM PT - May 4th, 2026

@robobun, your commit 9759b6d has some failures in Build #51128 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 28998

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

bun-28998 --bun

@coderabbitai

coderabbitai Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6bd3123e-13de-4392-b875-d6a61a2081bf

📥 Commits

Reviewing files that changed from the base of the PR and between 479c929847117a4e79ef75a8ea4f6e2c74c63783 and eee084f.

📒 Files selected for processing (2)
  • src/bun.js/node/node_fs.zig
  • test/regression/issue/28997.test.ts

Walkthrough

Native Zig cpSync implementation updated to create missing destination parent directories and retry on ENOENT/PATH_NOT_FOUND for macOS, Linux, and Windows symlink/copy paths. Added regression tests that exercise copying symlinks into non-existent (including nested) destination directories.

Changes

Cohort / File(s) Summary
Node filesystem symlink/copy handling
src/bun.js/node/node_fs.zig
Store initial copyfile result and on ENOENT create destination parent and retry for macOS regular-file path; on Linux symlink failure with ENOENT create parent and retry symlink; on Windows CreateSymbolicLinkW PATH_NOT_FOUND create parent with bun.makePathW(...) and retry.
Regression tests
test/regression/issue/28997.test.ts
Add two tests verifying cpSync with recursive: true copies symlinks into non-existent parent directories (including deeply nested), asserting created entries are symbolic links and contents match the original target.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: cpSync now creates parent directories when copying symlinks, which is the core fix.
Description check ✅ Passed The description includes both required sections (What and How), with comprehensive detail about the problem, reproduction case, root cause, and fix validation.
Linked Issues check ✅ Passed The PR directly addresses issue #28997 by implementing the ENOENT → makePath → retry fallback across all three platform-specific symlink branches (macOS, Linux, Windows) and adding regression tests.
Out of Scope Changes check ✅ Passed All changes are directly within scope: modifications to _copySingleFileSync symlink handling and addition of a regression test for issue #28997, with no unrelated alterations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai 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.

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.zig
  • test/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 {};

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.

🛠️ 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).

Comment thread src/bun.js/node/node_fs.zig Outdated
Comment on lines +6415 to +6422
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;

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.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +23 to +28
// 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");
});

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.

🟡 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

  1. symlinkSync('/tmp/x/target.md', '/tmp/x/link.md') — link.md points to /tmp/x/target.md
  2. 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')
  3. readlinkSync('/tmp/x/out2/link.md') returns '/tmp/x/link.md' (wrong — should be '/tmp/x/target.md')
  4. lstatSync('/tmp/x/out2/link.md').isSymbolicLink() -> true (passes incorrectly)
  5. 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.

Comment on lines +6303 to +6308
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;

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.

🟡 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):

  1. First open(src, O_NOFOLLOW) returns ELOOP → enter symlink branch.
  2. Syscall.symlink(src, dest) fails with ENOENT (destination dir missing).
  3. bun.makePath(...) catch {} is called but fails with EACCES — error discarded.
  4. Syscall.symlink(src, dest) is retried and fails again with ENOENT (dir still missing).
  5. 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);

Comment thread src/bun.js/node/node_fs.zig Outdated
@robobun robobun force-pushed the farm/ae1fb9fd/cpsync-symlink-mkdir branch from 479c929 to eee084f Compare April 8, 2026 12:37
Jarred-Sumner pushed a commit that referenced this pull request May 3, 2026
…#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>
@robobun robobun force-pushed the farm/ae1fb9fd/cpsync-symlink-mkdir branch from eee084f to 4068bc0 Compare May 3, 2026 07:21

@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 — 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() vs bun.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
@Jarred-Sumner Jarred-Sumner force-pushed the farm/ae1fb9fd/cpsync-symlink-mkdir branch from 4068bc0 to 9759b6d Compare May 4, 2026 10:38
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 {};

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.

🔴 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 ELOOP branch: same call
  • L6761 — Linux MLINK/LOOP branch: 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

  1. test/internal/ban-limits.json:39"std.fs.cwd": 109.
  2. git grep -c 'std\.fs\.cwd' -- 'src/**/*.zig' on the base commit (excluding src/codegen, src/deps, src/unicode/uucode) → ≤109.
  3. PR diff adds exactly 4 new std.fs.cwd() literals and removes none; ban-limits.json is not bumped.
  4. Post-PR count → ≥113 > 109.
  5. ban-words.test.ts throws "std.fs.cwd" is banned … found N > 109 instances → exit code 1.
  6. robobun's CI report for Build #50563 confirms test/internal/ban-words.test.ts failing 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.

Comment on lines +1 to +8
// 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";

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.

🟡 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

  1. 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."
  2. The PR description states the macOS/Linux/Windows symlink branches lacked the retry that the regular-file branch had — i.e., never implemented.
  3. Therefore cpSync fails with ENOENT when copying symlinks into non-existent directories #28997 is a never-correct bug, not a regression.
  4. 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/28997 comment 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.

xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…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>
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.

cpSync fails with ENOENT when copying symlinks into non-existent directories

1 participant