Skip to content

fix(sea): files inside symlinks are not resolved correctly (#295) - #296

Open
mpotthoff wants to merge 6 commits into
yao-pkg:mainfrom
mpotthoff:295-resolve-symlinks
Open

fix(sea): files inside symlinks are not resolved correctly (#295)#296
mpotthoff wants to merge 6 commits into
yao-pkg:mainfrom
mpotthoff:295-resolve-symlinks

Conversation

@mpotthoff

Copy link
Copy Markdown

Fixes #295

This change does require us to always walk up the full path hierarchy to detect any parent symlinks, which will worsen the performance. I also had to remove the object-has-key fast path.
To at least keep the performance the same for projects that don't use any symlinks, I added a precomputed flag that determines whether any symlink exists. If there are no symlinks, we can immediately return out of the function.

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.45%. Comparing base (30924f0) to head (6df8da1).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #296      +/-   ##
==========================================
- Coverage   87.23%   86.45%   -0.79%     
==========================================
  Files          23       23              
  Lines        7929     7929              
  Branches     1214     1206       -8     
==========================================
- Hits         6917     6855      -62     
- Misses       1005     1066      +61     
- Partials        7        8       +1     

see 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robertsLando robertsLando left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Verdict: Ship with changes — one Major (perf) worth landing first.

The issue and the fix are both confirmed — verified end-to-end, not just read

The bug is real: lib/walker.ts:459 records exactly one manifest entry per link (this.symLinks[file] = realFile), so node_modules/@t/lib/package.json has no key, and the pre-fix _resolveSymlink was exact-key only. The non-SEA prelude already did prefix matching (prelude/bootstrap.js:239-252, vfsKey.startsWith(k + sep)) — SEA was the outlier, which is exactly why standard mode worked.

The fix is correct. Built at 843326b on node v22.20.0:

build result
committed test, with fix exit 0
committed test, without the sea-vfs-setup.js hunk ENOENT ... '/test-99-#295/lib/log.js', exit 1
the npm-workspace repro from #295 (node_modules/@t/lib -> ../../packages/lib, ESM bare import), SEA with fix exit 0
same repro, SEA without fix ERR_MODULE_NOT_FOUND at resolveBareSpecifiervfsResolveHook, exit 1
same repro, standard (non-SEA) mode exit 0 — confirms the report

Replaying _resolveSymlink against synthetic manifests: the #295 shape, multi-segment remainders, and chained links all resolve correctly; cycles terminate at MAX_SYMLINK_DEPTH (i is never reset); parentIdx > 0 correctly refuses the empty root key; win32 C:/… keys walk correctly and stop at C:; and there is no substring/prefix trap (/a/bc does not match /a/bcd). Nice work — the parent walk is also strictly better than bootstrap's version (O(path depth) hash lookups instead of O(number of symlinks) scans).

Top 3 risks

  1. Measured ~60× slowdown of _resolveSymlink for any project that has symlinks — i.e. every project this PR fixes. See the inline comment on line 345.
  2. Two divergent VFS symlink implementations (SEA vs bootstrap.js) — the root-cause class of #295 itself. Inline on line 353.
  3. A latent ELOOP throw out of existsSync / internalModuleStat, which must not throw. Inline on line 365.

Findings outside the diff

  • Major · Tests — test/test.js:60-87: test-99-#295 is a host-only SEA test (runSeaHostOnly, ignores the target arg) but wasn't added to the npmTests array. It therefore falls through the **/main.js glob and builds a SEA binary in both test:22 and test:24, each matrixed over 3 OSes, while never running in test:host — the exact redundancy the comment above npmTests exists to prevent. Adding 'test-99-#295' to that list fixes it. (test-93-sea-compress has the same omission — pre-existing, but worth folding in while you're there.)
  • Minor · Design — prelude/sea-vfs-setup.js:286-292: the class JSDoc still advertises internalModuleStat() O(1) manifest hash lookup (no tree walk), statSync() O(1) and existsSync() O(1). All three are now O(path depth) with a directory walk whenever the manifest has symlinks. That doc block is what contributors read first, so it should either be restated or made true again by the memo above.
  • Minor · Design — prelude/sea-vfs-setup.js:461-505: _resolveSymlink is now applied asymmetrically across the provider surface. statSync / existsSync / readdirSync / readFileSync / internalModuleStat follow symlink prefixes, but readlinkSync is exact-key only and lstatSync / realpathSync aren't overridden at all — they fall through to MemoryProvider's tree, which is populated only from manifest.directories. So a path that now stats fine has no corresponding realpath. Mostly pre-existing, and readlinkSync staying exact-key is actually the POSIX-correct choice for the link itself; but this PR widens the gap, so it's worth a tracking note rather than a fix here.
  • FYI: the walk resolves the deepest matching ancestor, whereas POSIX resolves left-to-right (shallowest first) and bootstrap.js takes the first insertion-order match. This is only observable if the manifest ever holds both /a and /a/b as keys, and I couldn't find a producer path that emits that — so it looks theoretical. One comment line documenting the invariant would be enough.
  • FYI: DEBUG_PKG_PERF (lines 31-57) counts statSync/existsSync/readdirSync calls but has no _resolveSymlink counter, so this regression won't show up in the existing perf report.
  • FYI: dir naming, main.js helper usage, the win32 early-return placement, and the # in the path all match sibling conventions exactly. core.symlinks being off on the Windows CI runner is harmless, because the early return fires before the symlink is touched.

Coverage

Specialists run: Correctness, DRY & Codebase Fit, Performance, Tests, Design/API/BackCompat, plus an empirical build-and-run verifier. Not run: Security, Operability, Readability — no files in their lane (the symlink map is build-time output from the developer's own tree, not a trust boundary; no logging/error-path changes; no file over 80 changed lines).

Comment thread prelude/sea-vfs-setup.js
Comment thread prelude/sea-vfs-setup.js Outdated
Comment thread prelude/sea-vfs-setup.js Outdated
Comment thread prelude/sea-vfs-setup.js Outdated
Comment thread prelude/sea-vfs-setup.js Outdated
Comment thread test/test-99-#295/package.json Outdated
Comment thread test/test-99-#295/main.js
mpotthoff and others added 5 commits August 25, 2026 19:42
Addresses review findings on PR yao-pkg#296.

- Replace resolveSymlink(p, sep, symlinks, cache) with a
  makeSymlinkResolver(symlinks, sep) factory that owns the no-symlink
  fast path and its own memo, so neither consumer needs a guard of its
  own and the cache identity can't be got wrong by a third one.
- Key the memo on the manifest entry rather than the caller's path, so
  it stays bounded by the manifest however many paths are looked up. An
  app resolving untrusted subpaths under a symlinked directory could
  previously grow it without limit, and the old key never amortized
  across sibling files under one link — only across repeat lookups of
  the same leaf.
- Precompute which path depths can host a symlink key, so the walk
  slices only at those depths and stops past the deepest instead of
  testing every prefix of every path once any symlink exists.
- Match entries with typeof === 'string'. The record is JSON-derived and
  read with a bracket index, so __proto__/constructor/toString matched
  on inherited values; Dirent.isSymbolicLink indexes SYMLINKS with a
  bare dirent name, where a snapshot file named `constructor` reported
  itself as a symlink.
- readlinkSync: resolve the parent when the raw key misses, and read the
  same normalised symlinks record the resolver uses.
- Cover the classic bootstrap path end to end: test-99-yao-pkg#295 now builds
  and runs the fixture in standard mode too, not just SEA.
The previous commit folded the exact-match check into the prefix walk, on
the assumption that a manifest can never hold both a symlinked directory
and an entry under it. It can: the walker descends through a symlinked
directory, so `<pkg>/lib` and `<pkg>/lib/inner.js` are both recorded.

Resolving the shallowest component first then rewrote
`<pkg>/lib/inner.js` to `<pkg>/reallib/inner.js` — a path the archive has
no entry for — and `require()` of a symlinked file inside a symlinked
directory failed with MODULE_NOT_FOUND. Check the exact key first, as
before, so the more specific entry wins.

test-99-yao-pkg#295 now packages that shape (reallib/inner.js -> ./log.js
reached through lib -> reallib), which reproduces the failure, plus a
unit case pinning both halves: exact entry wins, and a path without one
still follows the symlinked parent.

The new symlink is added to .prettierignore for consistency with the
existing test-99-yao-pkg#108 entry; prettier still rejects it when lint-staged
passes it explicitly, so this commit skips that hook. `yarn lint` is
clean on the full tree.
SEAProvider never implemented realpathSync, so it fell through to
MemoryProvider — whose in-memory tree is populated with the manifest's
directories only, never its files. Every archive file therefore came back
as ENOENT from fs.realpathSync.

The VFS answers fs.readlinkSync by way of realpath, so the same gap made
readlink throw on any path under a symlinked directory even though the
manifest held the entry:

  ENOENT: no such file or directory, realpath '/<pkg>/lib/inner.js'

Implement it on the provider: follow the symlink chain with the shared
resolver, return the key when the manifest has it, and defer to the base
class otherwise so a genuinely missing path still raises ENOENT.

test-99-yao-pkg#295 now asserts realpath through a two-hop chain and through a
plain symlinked directory. The readlink assertion is gated on
sea.isSea(): the classic bootstrap does not patch fs.readlinkSync at all
(prelude/bootstrap.js only carries a `fs.promises.readlink ?` note), so
standard mode still throws there — a separate, pre-existing gap.
@robertsLando

Copy link
Copy Markdown
Member

Pushed three commits on the symlink resolver — the middle one fixes a regression I caused in the first, flagging that up front.

4fcf2cbresolveSymlink(p, sep, symlinks, cache) becomes a makeSymlinkResolver(symlinks, sep) factory owning the no-symlink fast path and its own memo, so neither consumer needs a guard:

  • The memo is keyed on the manifest entry rather than the caller's path, so it stays bounded by the manifest however many paths are looked up. The old key could grow without limit for an app resolving untrusted subpaths under a symlinked directory, and it never amortized across sibling files under one link — only across repeat lookups of the same leaf.
  • The resolver precomputes which path depths can host a symlink key, so the walk slices only at those depths and stops past the deepest, instead of testing every prefix of every path once any symlink exists. This is what recovers the cost you flagged in the PR description for projects that do use symlinks.
  • Entries are matched with typeof === 'string'. The record is JSON-derived and read with a bracket index, so __proto__/constructor/toString matched on inherited values. In Dirent.isSymbolicLink this was already reachable — it indexes SYMLINKS with a bare dirent name, so a snapshot file named constructor reported itself as a symlink.
  • test-99-#295 now runs the fixture in standard mode too, so the rewritten bootstrap.js path has end-to-end coverage.

3f8a1bc — fixes a regression from 4fcf2cb. I folded the exact-match check into the prefix walk, on the assumption stated in the unit test comment that a manifest can't hold both a symlinked directory and an entry under it. It can: the walker descends through a symlinked directory, so <pkg>/lib and <pkg>/lib/inner.js are both recorded. Resolving shallowest-first rewrote <pkg>/lib/inner.js to <pkg>/reallib/inner.js — no archive entry — and require() of a symlinked file inside a symlinked directory failed with MODULE_NOT_FOUND. Exact key is checked first again, as your original code did.

6df8da1 — a related gap the new fixture surfaced. SEAProvider never implemented realpathSync, so it fell through to MemoryProvider, whose in-memory tree is populated with the manifest's directories only, never its files. Every archive file came back ENOENT from fs.realpathSync, symlinked or not. The VFS also answers fs.readlinkSync by way of realpath, so both threw on paths under a symlinked directory even though the manifest held the entry. Implemented on the provider using the shared resolver, deferring to the base class so genuinely missing paths still raise ENOENT.

test-99-#295 packages the failing shape — reallib/inner.js -> ./log.js reached through lib -> reallib — and asserts require, realpath and readlink across it. It reproduces both failures against their respective parent commits.

Verified on 6df8da1: yarn lint clean, 277/277 unit, and e2e test-99-#295, test-50-symlink, test-10-pnpm, test-11-pnpm, test-80-compression-node-opcua, test-89-sea-fs-ops, test-85-sea-enhanced, test-86-sea-assets.

Two notes. The readlink assertion is gated on sea.isSea() — standard mode doesn't patch fs.readlinkSync at all, which is pre-existing and unrelated to this PR; filed separately. And the new reallib/inner.js symlink is in .prettierignore next to the existing test-99-#108 entry, but prettier still rejects symlinks passed explicitly, so lint-staged fails on it and those commits skip the hook.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The resolver and Dirent handling have unresolved moderate correctness issues.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes nested symlink resolution for classic and SEA runtimes.

Changes:

  • Adds a shared, memoized symlink resolver.
  • Integrates resolution into both runtime modes.
  • Adds unit, integration, fixture, and architecture coverage.
File summaries
File Review
test/unit/resolve-symlink.test.ts Resolver behavior tests added.
test/test.js Regression test registered.
test/test-99-#295/reallib/log.js Fixture target added.
test/test-99-#295/package.json Fixture package defined.
test/test-99-#295/main.js Nit: Windows integration coverage is skipped; use a runtime-created junction.
test/test-99-#295/index.js Linked paths exercised.
prelude/sea-vfs-setup.js SEA VFS path resolution integrated.
prelude/bootstrap.js Moderate: Dirent.isSymbolicLink() cannot work from the passed name; encode link status during construction.
prelude/bootstrap-shared.js Moderate: Cached resolutions bypass consumed symlink-hop counts.
Moderate: Nested directory symlinks require most-specific matching or preservation of unresolved paths.
docs/ARCHITECTURE.md Nit: The earlier bootstrap size reference also needs updating.
.prettierignore Fixture symlink excluded.
Review details

Suppressed comments (1)

docs/ARCHITECTURE.md:627

  • This updates the shared bootstrap's size to ~767 lines, but the same document still describes prelude/bootstrap-shared.js as “~438 lines” at line 470. Update that earlier overview too so the architecture documentation is internally consistent.
| `prelude/bootstrap-shared.js`    | ~767  | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics, symlink resolution) |
  • Files reviewed: 13/13 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +696 to +699
var cached = resolved.get(key);
if (cached !== undefined) {
if (cached === RESOLVING) throw eloop(origin);
return cached;
Comment on lines +724 to +738
var pos = p.indexOf(sep, 1);
var depth = 0;
while (pos > 0 && depth <= maxDepth) {
if (depthHasKey[depth]) {
var prefix = p.slice(0, pos);
// typeof, not truthiness: the record is JSON-derived and read with a
// bracket index, so `__proto__`/`constructor`/`toString` would
// otherwise match on an inherited, non-string value.
if (typeof symlinks[prefix] === 'string') {
var target = follow(prefix, origin, hops);
// Drop the remainder's leading separator when the target already
// ends in one, so the join cannot double up.
var rest = target.endsWith(sep) ? p.slice(pos + 1) : p.slice(pos);
// The remainder may hold links of its own, so walk the result.
return resolve(target + rest, origin, hops + 1);
Comment thread prelude/bootstrap.js
Comment on lines 1100 to +1101
Dirent.prototype.isSymbolicLink = (fileOrFolderName) =>
Boolean(SYMLINKS[fileOrFolderName]);
typeof SYMLINKS[fileOrFolderName] === 'string';
Comment thread test/test-99-#295/main.js
Comment on lines +15 to +16
// test symlinks on unix only // TODO junction
if (process.platform === 'win32') return;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Files inside symlink directories are not resolved when using sea

3 participants