nodejs / nodejs/node

fs.cpSync fails with EACCES when the destination is on a Docker bind mount (regression in 22.17)

未关闭
#65,497 1 条评论 1 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

主要语言
JavaScript
星标
122k
派生
37.4k
平均合并
4 天 3 小时
30 天内合并 PR
272

描述

Version

v22.17.1 (also v22.18.0, v22.20.0, v22.23.2, v24.19.0). Last good: v22.16.0.

Platform

Docker Desktop on macOS (Darwin 25.5.0, arm64), default VirtioFS file sharing. Reproduced with the official node:*-slim images, so the container distro is Debian bookworm.

Subsystem

fs

What steps will reproduce the bug?

fs.cpSync(src, dest, { recursive: true }) throws EACCES when dest is on a Docker bind mount. The same call succeeds on 22.16.0, and on 22.17.1 it succeeds when dest is on the container's own filesystem.

On a macOS host:

mkdir -p /tmp/repro/src/nested
printf 'a' > /tmp/repro/src/file.txt
printf 'b' > /tmp/repro/src/nested/inner.txt

for V in 22.16 22.17; do
  docker run --rm -v /tmp/repro:/mnt/repro -w /mnt/repro node:$V-slim node -e "
const fs = require('fs');
const dest = 'out-' + process.version.replace(/[^0-9]/g, '_');
try { fs.cpSync('src', dest, { recursive: true }); console.log(process.version, 'OK'); }
catch (e) { console.log(process.version, 'FAIL', e.code, e.syscall, e.path); }
"
done

Output:

v22.16.0 OK
v22.17.1 FAIL EACCES cp out-_22_17_1
How often does it reproduce?

Every time, on every version from 22.17.1 onward that I tested.

What is the expected behavior?

cpSync copies the tree, as it does on 22.16.0 and as cp -R does on the same path in the same container.

What do you see instead?
Error: EACCES: permission denied, cp 'out-_22_17_1'
Additional information

This is not a permission problem. In a single container run on 22.17.1, with the bind mount as CWD:

node v22.17.1
  FAIL dest on bind mount        EACCES cp bind-dest
  OK   dest on container fs
  control: id=0 access(W_OK)=yes
  control: mkdirSync on bind mount OK
  control: copyFileSync on bind mount OK
  control: cp -R OK

So, on the very path that cpSync rejects:

  • the process is root (uid 0) — it also fails as a non-root uid matching the host owner;
  • fs.accessSync(dir, W_OK) succeeds;
  • fs.mkdirSync succeeds;
  • fs.copyFileSync succeeds;
  • coreutils cp -R succeeds;
  • fs.cpSync with the same source and a destination on the container's own filesystem succeeds.

Only the combination of cpSync + a bind-mount destination fails.

cpSync does partially complete before throwing: the destination directory is created and at least one entry is copied, so a retry then sees EEXIST/ENOTEMPTY. That makes the failure messy to recover from in a build pipeline.

Bisect (identical mount, source, destination and uid; only the Node binary varies):

Version Result
22.13.1 OK
22.14.0 OK
22.16.0 OK
22.17.1 EACCES
22.18.0 EACCES
22.20.0 EACCES
22.23.2 EACCES
24.19.0 EACCES

The boundary lines up with the C++ reimplementation of cpSync, which landed in 22.17. The mechanism is below.

Real-world impact. @nx/next:build finishes by copying the app's public/ directory into its output path with exactly this call:

cpSync(join(projectRoot, 'public'), join(options.outputPath, 'public'), {
  dereference: true,
  recursive: true,
});

Any Next.js build run inside a container against a bind-mounted workspace therefore fails on Node ≥ 22.17 — after the build has already compiled and typechecked successfully, so it presents as a mysterious late-stage permission error rather than an obvious environment problem. Related historical regressions in the same function: #54285, #54782.

Root cause. Reduced to raw syscalls in the container, as root:

FAIL open dest O_WRONLY|O_CREAT|O_TRUNC mode 0200   errno=13 (Permission denied)
OK   open dest O_WRONLY|O_CREAT|O_TRUNC mode 0644
FAIL reopen the 0200 file O_WRONLY                  errno=13 (Permission denied)

libstdc++'s do_copy_file creates the destination with S_IWUSR (0200) and fchmods it to the source's mode afterwards. This mount refuses to open a mode-0200 file for writing, so the create fails. uv_fs_copyfile creates the destination with the source's mode and is unaffected.

That is confirmed outside Node entirely — plain C++ with gcc 14 and libstdc++, same mount:

FAIL copy_file  mount -> mount   value=13 msg=Permission denied
OK   copy_file  mount -> /tmp
OK   copy_file  /tmp  -> /tmp
FAIL copy_file  /tmp  -> mount   value=13 msg=Permission denied

Which is why cpSync is inconsistent with itself, on one mount in one process:

Call Result Path taken
cpSync(file, newDest) OK JS falls through to copyFileSync (libuv)
cpSync(file, existingDest) EACCES CpSyncOverrideFile, mode == 0 branch
cpSync(dirWithAFile, dest) EACCES CpSyncCopyDir, regular-file branch
cpSync(dirNoFiles, dest) OK no file is ever copied

fs.copyFileSync works on this mount because it uses uv_fs_copyfile. Both failing paths already have a libuv sibling in node_file.cc; they just do not take it.

Workaround. Pin to 22.16.x, or arrange for the destination to live on the container's own filesystem rather than the bind mount.

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从 node_file.cc 中报告所述的 C++ cpSync 代码路径开始,重点检查使用 libstdc++ 文件复制而不是现有 libuv 对应实现的普通文件分支。使用 Docker bind mount 命令重现,并将 cpSync 与 copyFileSync 进行比较。当递归 cpSync 能够在受影响的 bind mount 上成功复制文件,且不再出现部分复制失败时,即表示完成。

由索引模型根据 Issue 内容生成。

评估

技术栈
cpp, javascript, node.js
领域
backend, operating-systems
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
活跃
描述清晰度
描述清楚
新手友好度
68/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。