Skip to content

node:fs: recursive readdir names each entry by its full path with the leading slash dropped #7678

Open

Description

@oddharsh

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions