Skip to content

node:fs: symlink stores a relative target resolved against process.cwd() #7679

Open

Description

@oddharsh

symlinkSync(target, path) resolves a relative target against process.cwd() (/bundle) when it creates the link, and stores the result. Node.js stores the target string as written and resolves it from the link's own directory each time the link is followed. In workerd, readlinkSync therefore returns a different string, and reading through the link fails unless the target happens to exist under /bundle.

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, symlinkSync, readlinkSync, readFileSync } from 'node:fs';
import { strictEqual } from 'node:assert';

export const relativeSymlinkTargetIsKeptAndResolvedFromTheLink = {
  test() {
    mkdirSync('/tmp/r/sub', { recursive: true });
    writeFileSync('/tmp/r/sub/x.txt', 'x');

    symlinkSync('sub/x.txt', '/tmp/r/link');
    // Node.js: 'sub/x.txt' (the target string as written)
    strictEqual(readlinkSync('/tmp/r/link'), 'sub/x.txt');
    // Node.js: 'x' (a relative target resolves from the link's directory)
    strictEqual(readFileSync('/tmp/r/link', 'utf8'), 'x');

    symlinkSync('../sub/x.txt', '/tmp/r/sub/up');
    strictEqual(readlinkSync('/tmp/r/sub/up'), '../sub/x.txt');
    strictEqual(readFileSync('/tmp/r/sub/up', 'utf8'), 'x');
  },
};

workerd test repro.wd-test fails on the first assertion:

[Diff] Actual / Expected
-   /bundle/sub/x.txt
+   sub/x.txt

The same repro.js passes under Node.js v26.11.0, with test() called directly against the real /tmp.

step Node.js workerd
symlinkSync('sub/x.txt', '/tmp/r/link'), then readlinkSync sub/x.txt /bundle/sub/x.txt
readFileSync('/tmp/r/link', 'utf8') x ENOENT
symlinkSync('../sub/x.txt', '/tmp/r/sub/up'), then readlinkSync ../sub/x.txt /sub/x.txt
readFileSync('/tmp/r/sub/up', 'utf8') x ENOENT

Absolute targets work as expected.

Where it comes from

symlinkSync passes the target through the same normalizePath as the link path (internal_fs_sync.ts#L786), and normalizePath resolves a relative string against file://${cwd}/ (internal_fs_utils.ts#L170-L173). The callback form normalizes the target the same way (internal_fs_callback.ts#L1199), and fs/promises calls symlinkSync.

On the C++ side, SymbolicLink holds only an absolute kj::Path (worker-fs.h#L497-L501), and readLink returns it (filesystem.c++#L342). Once the link exists, the relative form is gone. Matching Node.js means storing the target as written. A relative target would then resolve against the link's parent directory each time the link is followed, which changes SymbolicLink itself.

Why the existing tests pass

Every symlinkSync call in fs-link-test.js uses an absolute target (/dev/null, /tmp, /does/not/exist), so the relative case never runs.

Impact

Relative links keep working when the tree around them moves. In workerd, a relative link points somewhere else as soon as it exists. I hit it running a Node.js test suite inside workerd: a dev staging step builds its symlink farm with symlink(relative(dirname(at), source), at), so none of its links resolved.

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