Repository navigation
node:fs: recursive readdir names each entry by its full path with the leading slash dropped #7678
Description
readdirSync(dir, { recursive: true }) returns each entry as its full path minus the leading slash. Node.js returns it relative to dir. With withFileTypes: true, Dirent.name carries the same full path, where Node.js gives the entry's own name; parentPath is already right.
Reproduced on workerd 1.20261009.1 from npm, and the source it comes from is unchanged on main at e3449da.
Repro
repro.wd-test:
using Workerd = import "/workerd/workerd.capnp";
const unitTests :Workerd.Config = (
services = [
( name = "repro",
worker = (
modules = [ (name = "worker", esModule = embed "repro.js") ],
compatibilityDate = "2026-10-01",
)
),
],
);repro.js:
import { mkdirSync, writeFileSync, readdirSync } from 'node:fs';
import { deepStrictEqual } from 'node:assert';
export const recursiveReaddirNamesAreRelativeToTheDirectory = {
test() {
mkdirSync('/tmp/d/sub', { recursive: true });
writeFileSync('/tmp/d/a.txt', 'a');
writeFileSync('/tmp/d/sub/b.txt', 'b');
// Node.js: ['a.txt', 'sub', 'sub/b.txt']
deepStrictEqual(readdirSync('/tmp/d', { recursive: true }).sort(), ['a.txt', 'sub', 'sub/b.txt']);
// Node.js: name is the entry's own name; parentPath is the directory holding it
const ents = readdirSync('/tmp/d', { recursive: true, withFileTypes: true })
.map((e) => [e.name, e.parentPath])
.sort();
deepStrictEqual(ents, [['a.txt', '/tmp/d'], ['b.txt', '/tmp/d/sub'], ['sub', '/tmp/d']]);
},
};workerd test repro.wd-test fails on the first assertion:
+ actual - expected
+ [ 'tmp/d/a.txt', 'tmp/d/sub', 'tmp/d/sub/b.txt' ]
- [ 'a.txt', 'sub', 'sub/b.txt' ]
The same repro.js passes under Node.js v26.11.0, with each test() called directly against the real /tmp.
| call | Node.js | workerd |
|---|---|---|
readdirSync('/tmp/d', { recursive: true }) |
a.txt, sub, sub/b.txt |
tmp/d/a.txt, tmp/d/sub, tmp/d/sub/b.txt |
same, withFileTypes: true, entry for b.txt |
name b.txt, parentPath /tmp/d/sub |
name tmp/d/sub/b.txt, parentPath /tmp/d/sub |
readdirSync('data', { recursive: true }), cwd /bundle |
a.txt, ... |
bundle/data/a.txt, ... |
Where it comes from
FileSystemModule::readdir starts readdirImpl from the absolute normalizedPath (filesystem.c++#L1279). In recursive mode, readdirImpl then names every entry path.append(entry.key).toString(false) (#L1217), which is that absolute path without its leading slash. A non-recursive read uses entry.key and matches Node.js.
Node.js names a string result by its path relative to the directory passed in, and a Dirent by its own name, with the directory in parentPath. readdirSync builds both from the same handle.name (internal_fs_sync.ts#L550-L573). So the handle would need to carry both the relative path and the bare name, or readdirImpl would need to know which one the caller wants. readdir and fs/promises both go through readdirSync, so they share the bug and the fix.
Why the existing tests pass
fs-dir-test.js only reads recursively from /. From the root, "relative to the directory" and "absolute minus the leading slash" are the same strings, so string results look right. For Dirents, the test asserts today's output: strictEqual(ents[1].name, 'bundle/worker') with parentPath /bundle (#L645, and again at L743 and L820). Node.js gives worker there, so a fix changes those three assertions. I'm not sure whether that needs a compat flag; that's your call.
Impact
Node.js code that walks a tree with readdirSync(dir, { recursive: true }) and joins each entry onto dir builds /tmp/d/tmp/d/a.txt and gets ENOENT. I hit it running a Node.js test suite inside workerd: a test that lists src/pages recursively went looking for /bundle/src/pages/bundle/src/pages/access/index.html.
Activity