Skip to content

lib: Fix infinite loop on paths with nested links#61391

Open
mattskel wants to merge 4 commits intonodejs:mainfrom
mattskel:fix-60295-fs.realpath-infinite-loop-squash
Open

lib: Fix infinite loop on paths with nested links#61391
mattskel wants to merge 4 commits intonodejs:mainfrom
mattskel:fix-60295-fs.realpath-infinite-loop-squash

Conversation

@mattskel
Copy link

Fixes #60295

Get the link target head realpath.
Then resolve realpath and link target tail.
Resolve tail after resolving the link head.
Fixes: nodejs#60295
@nodejs-github-bot nodejs-github-bot added fs Issues and PRs related to the fs subsystem / file system. needs-ci PRs that need a full CI run. labels Jan 15, 2026
@mattskel mattskel marked this pull request as ready for review January 15, 2026 13:14
@codecov
Copy link

codecov bot commented Jan 15, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.54%. Comparing base (2679b62) to head (2914f1e).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #61391      +/-   ##
==========================================
+ Coverage   88.52%   88.54%   +0.02%     
==========================================
  Files         704      704              
  Lines      208796   208819      +23     
  Branches    40312    40324      +12     
==========================================
+ Hits       184828   184891      +63     
+ Misses      15955    15908      -47     
- Partials     8013     8020       +7     
Files with missing lines Coverage Δ
lib/fs.js 98.20% <100.00%> (+0.01%) ⬆️

... and 30 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.

@targos
Copy link
Member

targos commented Jan 16, 2026

Please add a test.

test: cover realpathSync hang with symlink and ..
@mattskel
Copy link
Author

@targos please see test added.

@targos
Copy link
Member

targos commented Jan 20, 2026

@nodejs/fs PTAL

@louwers
Copy link
Contributor

louwers commented Jan 23, 2026

Can you explain how the fix works?

@mattskel
Copy link
Author

Can you explain how the fix works?

@louwers

realPathSync can enter an infinite loop when resolving a symlink whose target contains relative segments like ... path.resolve() normalises these segments, without considering symlinks. In cases like d -> "c/../d", this collapses back to d before c is ever resolved as a symlink. The algorithm then “restarts” from the same path and never makes progress.

Fix

When a symlink target is read, only the first path component is resolved immediately. The remaining segments are stored as an unresolvedTail. This ensures the resolution restarts at a filesystem-visible step (e.g. c) rather than a lexically collapsed path. Once all symlink-sensitive components have been resolved, the deferred tail is appended and normalised. And the loop continues resolving the path.

By preventing .. from being applied before intermediate symlinks are resolved, the algorithm guarantees that symlinks are expanded before relative path segments are normalised, which prevents the path from resolving back to itself.

Comment on lines +2784 to +2787
if (unresolvedTail !== '') {
p = pathModule.resolve(p + unresolvedTail);
unresolvedTail = '';
}
Copy link
Author

Choose a reason for hiding this comment

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

flush the deferred tail as soon as it’s safe

Comment on lines +2809 to +2813
const nextPathDelimiterIndex = nextPart(linkTarget, 0);
if (nextPathDelimiterIndex >= 1) {
unresolvedTail = StringPrototypeSlice(linkTarget, nextPathDelimiterIndex) + unresolvedTail;
linkTarget = StringPrototypeSlice(linkTarget, 0, nextPathDelimiterIndex);
}
Copy link
Author

Choose a reason for hiding this comment

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

This code splits a symlink target into two parts: the first path segment, which is resolved immediately, and the remaining path segments, which are deferred.

Comment on lines -2735 to +2747
const result = nextPart(p, pos);
let unresolvedTail = '';
while (true) {
if (pos >= p.length) {
if (unresolvedTail === '') {
break;
}

p = pathModule.resolve(p + unresolvedTail);
unresolvedTail = '';
current = base = splitRoot(p);
pos = current.length;
continue;
}

const result = nextPart(p + unresolvedTail, pos);
Copy link
Author

Choose a reason for hiding this comment

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

Previously, the loop terminated once pos >= p.length. With the introduction of unresolvedTail, reaching the end of p does not necessarily mean resolution is complete. If the path has been fully scanned but deferred segments remain, the traversal state is reset and the unresolved tail is applied so resolution can continue.

Otherwise, path segmentation is performed on p + unresolvedTail, allowing deferred symlink target segments to be traversed as a continuation of the original path.

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

Labels

fs Issues and PRs related to the fs subsystem / file system. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fs.realpath infinite loop

4 participants