fs.cpSync fails with EACCES when the destination is on a Docker bind mount (regression in 22.17)
还没有人认领这个 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.mkdirSyncsucceeds;fs.copyFileSyncsucceeds;- coreutils
cp -Rsucceeds; fs.cpSyncwith 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.
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 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