Repository navigation
node:fs: symlink stores a relative target resolved against process.cwd() #7679
Description
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