Skip to content

fix: report snapshot symlinks as links in lstat, readlink and readdir - #310

Merged
robertsLando merged 1 commit into
mainfrom
feat/symlink-reporting
Nov 2, 2026
Merged

robertsLando merged 1 commit into
mainfrom
feat/symlink-reporting

Conversation

@robertsLando

@robertsLando robertsLando commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #296, split out so the #295 fix didn't wait on a vfs release. Needs @roberts_lando/vfs ^0.4.0 (robertsLando/vfs#4), which this PR bumps.

What changes

Inside a packaged binary, snapshot symlinks used to look like what they point at. Now both modes answer from their own symlink record, the way Node does outside a binary:

Traditional Enhanced SEA
readdir({ withFileTypes: true }) link reports as a link same, from manifest.symlinks
lstat describes the link: S_IFLNK type bits, size of the target path same
readlink absolute target; EINVAL for a non-link, ENOENT for a missing path same; C:\snapshot\... form on Windows, like realpath (#305)

One shared linkTarget() in bootstrap-shared.js decides what is a link for all three calls in both modes. It also finds links the walker recorded only under their real parent: lib/other.js, where lib -> reallib and the walker only saw reallib/other.js. Dirent, asSymlinkStat and applyReadlinkEncoding are shared too. Both modes find the link through resolveKey.parent(), a new resolver step that follows a path's parents but not its last component, which is what POSIX does before reading a link. Dirent drops the path alias on Node 24, as fs.Dirent does.

In SEA, readlink applies the caller's encoding after the VFS adds the mount prefix, so 'hex'/'base64' targets keep /snapshot.

The walker now keys a link that sits inside a linked directory under the real directory, and records the directory link with it. Before, an app that required lib/inner.js (with lib -> reallib and reallib/inner.js a link) got reallib/inner.js missing from the snapshot: every fs call on that spelling threw ENOENT, on 6.24.0 too. Both spellings resolve now.

Not a breaking change

This is a bug fix: packaged apps now get what plain node gives. Code that relied on pkg's old, wrong answer may notice, but only when the snapshot contains symlinks (pnpm and workspace node_modules, node_modules/.bin, links among assets):

// node_modules/plugin-a -> .pnpm/plugin-a@1.0.0/node_modules/plugin-a
fs.readdirSync(nm, { withFileTypes: true }).filter((d) => d.isDirectory());
// 6.24.0 traditional: ['plugin-a']   now: []   (plain node: [])
  • d.isDirectory() / d.isFile() filters skip linked entries, and glob without follow: true no longer descends into linked directories. fast-glob and readdirp follow links by default and are unaffected.
  • lstatSync(link).isDirectory() / .isFile() are false.
  • readlink returns the walker's absolute, resolved target, not the raw link body. It always points inside the snapshot. Tools that re-create links from it (tar, archiver, hand-written copies) write absolute /snapshot/... links; fs.cp can't copy out of the snapshot in either mode, before or after this PR.

Everything except the readlink value matches unpackaged Node, so code that works under node keeps working packaged. In practice only traditional mode changes: in SEA, 6.24.0's withFileTypes entries had no Dirent methods, so nothing could rely on them. The user guide (Snapshot filesystem → Symlinks) and docs/ARCHITECTURE.md document it.

Tests

  • test-99-#295 asserts one contract for both modes, on node22 and node24:
    • readlink: full value, 'buffer' and 'hex' encodings, EINVAL / ENOENT with the caller's path
    • lstat on directory links, file links, links reached through the real parent, a nested link by its real path, plain files reached through a linked dir, and missing paths
    • readdir of the linked and the real directory, with the exact parentPath
  • Reverting the walker change, or the SEA readlink encoding fix, fails the test.
    • callback and promise forms
  • test/unit/dirent-shared.test.ts covers linkTarget, Dirent, asSymlinkStat and applyReadlinkEncoding.

@robertsLando
robertsLando marked this pull request as ready for review October 23, 2026 08:12

@robertsLando robertsLando left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

deep-review: Ship with minor changes

Scope: this PR's own two commits only (15b6da1..a17221e): 11 files, +769 / −64. #296's commits were not reviewed here. CI: 28/28 green.

Merge order: stacked on #296, which is still open, and #296's remote head (f0d409d) lacks 15b6da1 refactor(prelude): narrow #296 to symlink resolution. Land that on #296 first, then rebase this PR.

Top risks: (1) readlink returns the absolute resolved target, which a major release makes the contract; (2) SEA readlink with hex/base64 encoding loses the /snapshot prefix; (3) classic and SEA throw different readlink ENOENT errors.

Strengths: builds on #296's shared resolver instead of adding a second path, uses vfs 0.4.0's own extension points (lstatSync/readlinkSync), and Security came back clean.

Not anchored to a diff line

  • Minor Design/API docs-site/guide/snapshot-fs.md:breaking-change-absent-user-docs: the breaking-change notice and workarounds (glob follow: true, fs.cp dereference) live only in the contributor-facing docs/ARCHITECTURE.md. Add a short Symlinks section to the user guide.
  • Minor Readability: comment trims. Cut the 5-line readlink banner in bootstrap.js, the history comments in getFileTypes, the parentPath alias of path_, the 3-line toCallerPath comment, the vfs#4 reference in SEAProvider.readlinkSync, and doc blocks in bootstrap-shared.js that are longer than their bodies.
  • Nit _einval/_enoent are near-identical; readFileSync parses the encoding inline, duplicating readlinkEncoding; the local noop duplicates the shared alwaysFalse; the typeof === 'string' inherited-key guard in linkTarget has no caller passing such keys; the ARCHITECTURE.md line count goes stale; the body of commit 8179dd6 still says package.json pins the vfs PR commit (fix in the squash message).

FYI

  • A dangling link (readdir says link, lstat throws ENOENT) is not reachable today: Walker.appendSymlink always records a stat for realFile. If it ever matters, build a link stat instead of throwing.
  • Promise and callback forms reach the new code in both modes. SEA: vfs 0.4.0 routes fs.readlink/fs.lstat/fs.promises.* to the provider. Classic: util.promisify.

Coverage: Correctness, Security, Design/API, Simplicity, DRY, Readability, Operability, Tests. Clean on: Security. Not run: Performance (no files in its lane).

Comment thread prelude/sea-vfs-setup.js Resolved
Comment thread prelude/sea-vfs-setup.js Resolved
Comment thread prelude/bootstrap.js Resolved
Comment thread prelude/bootstrap.js Resolved
Comment thread prelude/sea-vfs-setup.js Resolved
Comment thread test/test-99-#295/index.js Resolved
Comment thread test/test-99-#295/index.js Resolved
Comment thread test/test-99-#295/index.js Resolved
Comment thread test/unit/dirent-shared.test.ts Resolved
@codecov

codecov Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 55.55556% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.62%. Comparing base (f5af0a0) to head (eb9ed2e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
lib/walker.ts 55.55% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #310      +/-   ##
==========================================
- Coverage   87.65%   87.62%   -0.04%     
==========================================
  Files          23       23              
  Lines        7917     7926       +9     
  Branches     1213     1214       +1     
==========================================
+ Hits         6940     6945       +5     
- Misses        970      974       +4     
  Partials        7        7              
Files with missing lines Coverage Δ
lib/walker.ts 87.14% <55.55%> (-0.22%) ⬇️
🚀 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 changed the title feat(prelude)!: report snapshot symlinks in lstat, readlink and readdir feat!: report snapshot symlinks in lstat, readlink and readdir Nov 2, 2026
@robertsLando

Copy link
Copy Markdown
Member Author

finalize-pr — 4 rounds (all full), merge-ready

Decided

  • readlink returns the walker's absolute resolved target, not the raw link body. It always points inside the snapshot, and fs.cp can't copy out of the snapshot in either mode. Documented in the user guide and ARCHITECTURE.md.
  • The pre-existing walker gap (a link inside a linked directory missing under its real path) is fixed here instead of in a separate issue.

Moved to #296 — 4 Minor notes on #296's own code, posted before it merged: macOS ELOOP errno, the symlink memo, history-style comments, repeated resolver test comments.

Base sync — main merged in after #296's squash-merge (one conflict pass, no force-push); round 4 reviewed the result in full.

Dropped (Minor/Nit, with reason)

  • Trailing-slash lstat('lnk/') reports the link where Node follows it: the vfs strips the slash before SEAProvider sees it, so a fix that keeps both modes the same needs a vfs change.
  • Build fs.Dirent directly: its constructor is undocumented; the classic bootstrap had its own Dirent already.
  • Classic readlink ENOENT message differs from SEA: classic uses one error_ENOENT for every fs call.
  • Missing-target link branches untested: unreachable, since the walker always records the target stat.
  • SEA parentPath is normalised: the provider never sees the caller's spelling; test-99 asserts it equals the normalised path both modes get.
  • Walker re-key tested one level deep: a test that also requires the real path would mask a walker revert; a unit case covers the shape the walker emits.
  • Classic noop vs shared alwaysFalse: optional.
  • readdir ENOTDIR / missing-directory tests: those error paths fall back to super as before; this PR doesn't change them.

@robertsLando robertsLando changed the title feat!: report snapshot symlinks in lstat, readlink and readdir fix: report snapshot symlinks as links in lstat, readlink and readdir Nov 2, 2026
Inside a packaged binary, snapshot symlinks looked like what they point
at, unlike outside a binary. Both modes now answer from their symlink
record the way Node does:

- readdir({ withFileTypes: true }) reports a link as a link
- lstat describes the link: S_IFLNK type bits, size of the target path
- readlink returns the absolute target in the snapshot; EINVAL for a
  non-link, ENOENT for a missing path; any encoding, including hex and
  base64 in SEA
- Dirent drops the path alias on Node 24, as fs.Dirent does

The walker now keys a link inside a linked directory under the real
directory, so reallib/inner.js resolves when the app only required
lib/inner.js (it threw ENOENT before).

Requires @roberts_lando/vfs ^0.4.0 (robertsLando/vfs#4).
@robertsLando
robertsLando force-pushed the feat/symlink-reporting branch from 64ab493 to eb9ed2e Compare November 2, 2026 08:18
@robertsLando
robertsLando merged commit d6ee147 into main Nov 2, 2026
10 checks passed
@robertsLando
robertsLando deleted the feat/symlink-reporting branch November 2, 2026 08:19
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.

1 participant