fs.cpSync fails with EACCES when the destination is on a Docker bind mount (regression in 22.17)
Nessuno ha ancora preso questa issue.
- Lingua principale
- JavaScript
- Stelle
- 122k
- Fork
- 37.3k
- Merge medio
- 4g 2h
- PR unite (30g)
- 283
Descrizione
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.
Guida per i contributori
Apri la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Direzione di ricerca
Inizia in node_file.cc nei percorsi di codice C++ di cpSync indicati nel report, soprattutto nei rami per i file regolari che usano la copia dei file di libstdc++ invece del corrispondente esistente di libuv. Riproduci il problema con i comandi Docker per il bind mount e confronta cpSync con copyFileSync. Il lavoro è completato quando cpSync ricorsivo copia correttamente i file sul bind mount interessato senza il fallimento della copia parziale.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- cpp, javascript, node.js
- Ambito
- backend, operating-systems
- Tipo di issue
- Bug
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Stato di attività
- Attiva
- Chiarezza
- Specificata chiaramente
- Idoneità per principianti
- 68/100