fs.cpSync fails with EACCES when the destination is on a Docker bind mount (regression in 22.17)
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- JavaScript
- Star
- 122k
- Fork
- 37.4k
- Merge trung bình
- 4 ngày 3 giờ
- Pull request đã merge (30 ngày)
- 272
Mô tả
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.
Hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu trong node_file.cc tại các đường dẫn mã C++ của cpSync được nêu trong báo cáo, đặc biệt là các nhánh dành cho tệp thông thường sử dụng cơ chế sao chép tệp của libstdc++ thay vì cơ chế tương ứng hiện có của libuv. Tái hiện bằng các lệnh Docker cho bind mount và so sánh cpSync với copyFileSync. Hoàn thành khi cpSync đệ quy sao chép tệp thành công trên bind mount bị ảnh hưởng mà không xảy ra lỗi sao chép một phần.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- cpp, javascript, node.js
- Lĩnh vực
- backend, operating-systems
- Loại issue
- Lỗi
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức độ hoạt động
- Sôi nổi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức phù hợp với người mới
- 68/100