Skip to content

Commit b9cdb3d

Browse files
jazellyaduh95
authored andcommitted
fs: resolve symlinks before parent components
The JavaScript implementation of `fs.realpath()` and `fs.realpathSync()` joined a symlink target with the rest of the path through `path.resolve()`, which collapses `..` lexically before the preceding components have been resolved. A link such as `d -> c/../d`, where `c` is itself a symlink, was therefore rewritten back to `d` and the walk never terminated. Keep `.` and `..` in the path while walking it, and apply `..` to the already resolved parent, matching `realpath(3)` and `fs.realpathSync.native()`. Only normalize the final result, and skip caching link targets containing `..`, whose resolution depends on the path walked before them. Thsi is a POSIX only fix. Windows is unaffected. Signed-off-by: jazelly <xzha4350@gmail.com> PR-URL: #65457 Fixes: #60295 Reviewed-By: Jake Yuesong Li <jake.yuesong@gmail.com>
1 parent a30f200 commit b9cdb3d

2 files changed

Lines changed: 175 additions & 11 deletions

File tree

‎lib/fs.js‎

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3339,6 +3339,25 @@ if (isWindows) {
33393339
};
33403340
}
33413341

3342+
// realpath(3) applies `..` to the resolved parent, so on POSIX `.` and `..`
3343+
// are kept in the path while walking it. Windows applies `..` lexically, both
3344+
// in the path being opened and in symlink targets, so the path and the link
3345+
// targets are normalized up front there, matching `fs.realpathSync.native()`.
3346+
function toRealpathInput(p) {
3347+
if (isWindows) return pathModule.resolve(p);
3348+
return pathModule.isAbsolute(p) ? p : `${pathModule.resolve()}/${p}`;
3349+
}
3350+
3351+
function joinRealpathLink(previous, linkTarget) {
3352+
if (isWindows) return pathModule.resolve(previous, linkTarget);
3353+
return pathModule.isAbsolute(linkTarget) ? linkTarget : `${previous}${linkTarget}`;
3354+
}
3355+
3356+
function joinRealpathRemainder(resolvedLink, remainder) {
3357+
if (isWindows) return pathModule.resolve(resolvedLink, remainder);
3358+
return remainder.length === 0 ? resolvedLink : `${resolvedLink}/${remainder}`;
3359+
}
3360+
33423361
/**
33433362
* Returns the resolved pathname.
33443363
* @param {string | Buffer | URL} p
@@ -3357,7 +3376,7 @@ function realpathSync(p, options) {
33573376
p += '';
33583377
}
33593378
validatePath(p);
3360-
p = pathModule.resolve(p);
3379+
p = toRealpathInput(p);
33613380

33623381
// Check the resolved input path up front so that the lstat walk below cannot
33633382
// be used to probe the existence of paths outside the granted set.
@@ -3371,14 +3390,14 @@ function realpathSync(p, options) {
33713390
}
33723391

33733392
const cache = options[realpathCacheKey];
3374-
const maybeCachedResult = cache?.get(p);
3393+
const original = p;
3394+
const maybeCachedResult = cache?.get(original);
33753395
if (maybeCachedResult) {
33763396
return maybeCachedResult;
33773397
}
33783398

33793399
const seenLinks = new SafeMap();
33803400
const knownHard = new SafeSet();
3381-
const original = p;
33823401
// Whether the symlink this walk resolved last pointed at a pipe or a
33833402
// socket, which is where the walk stops. It cannot be read back from the
33843403
// shared stat buffer, which holds the last stat made anywhere in the
@@ -3414,6 +3433,9 @@ function realpathSync(p, options) {
34143433
while (pos < p.length) {
34153434
// find the next part
34163435
const result = nextPart(p, pos);
3436+
const part = result === -1 ?
3437+
StringPrototypeSlice(p, pos) :
3438+
StringPrototypeSlice(p, pos, result);
34173439
previous = current;
34183440
if (result === -1) {
34193441
const last = StringPrototypeSlice(p, pos);
@@ -3426,6 +3448,19 @@ function realpathSync(p, options) {
34263448
pos = result + 1;
34273449
}
34283450

3451+
if (part === '' || part === '.') {
3452+
current = previous;
3453+
continue;
3454+
}
3455+
if (part === '..') {
3456+
base = pathModule.dirname(previous);
3457+
current = base;
3458+
if (current[current.length - 1] !== CHAR_FORWARD_SLASH &&
3459+
current[current.length - 1] !== CHAR_BACKWARD_SLASH) {
3460+
current += pathModule.sep;
3461+
}
3462+
continue;
3463+
}
34293464
// Continue if not a symlink, break if a pipe/socket
34303465
if (knownHard.has(base) || cache?.get(base) === base) {
34313466
if (reachedPipeOrSocket) {
@@ -3471,14 +3506,18 @@ function realpathSync(p, options) {
34713506
isFileType(targetStats, S_IFSOCK);
34723507
linkTarget = binding.readlink(base, undefined);
34733508
}
3474-
resolvedLink = pathModule.resolve(previous, linkTarget);
3509+
resolvedLink = joinRealpathLink(previous, linkTarget);
34753510

3476-
cache?.set(base, resolvedLink);
3511+
// On POSIX, a target containing `..` resolves relative to the path
3512+
// walked before it, so it cannot be cached by the link alone.
3513+
if (isWindows || StringPrototypeIndexOf(linkTarget, '..') === -1) {
3514+
cache?.set(base, pathModule.resolve(resolvedLink));
3515+
}
34773516
if (!isWindows) seenLinks.set(id, linkTarget);
34783517
}
34793518

34803519
// Resolve the link, then start over
3481-
p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
3520+
p = joinRealpathRemainder(resolvedLink, StringPrototypeSlice(p, pos));
34823521

34833522
// Skip over roots
34843523
current = base = splitRoot(p);
@@ -3495,6 +3534,7 @@ function realpathSync(p, options) {
34953534
}
34963535
}
34973536

3537+
p = pathModule.resolve(p);
34983538
cache?.set(original, p);
34993539
return encodeRealpathResult(p, options);
35003540
}
@@ -3547,7 +3587,7 @@ function realpath(p, options, callback) {
35473587
p += '';
35483588
}
35493589
validatePath(p);
3550-
p = pathModule.resolve(p);
3590+
p = toRealpathInput(p);
35513591

35523592
const seenLinks = new SafeMap();
35533593
const knownHard = new SafeSet();
@@ -3585,11 +3625,14 @@ function realpath(p, options, callback) {
35853625
function LOOP() {
35863626
// Stop if scanned past end of path
35873627
if (pos >= p.length) {
3588-
return callback(null, encodeRealpathResult(p, options));
3628+
return callback(null, encodeRealpathResult(pathModule.resolve(p), options));
35893629
}
35903630

35913631
// find the next part
35923632
const result = nextPart(p, pos);
3633+
const part = result === -1 ?
3634+
StringPrototypeSlice(p, pos) :
3635+
StringPrototypeSlice(p, pos, result);
35933636
previous = current;
35943637
if (result === -1) {
35953638
const last = StringPrototypeSlice(p, pos);
@@ -3602,10 +3645,23 @@ function realpath(p, options, callback) {
36023645
pos = result + 1;
36033646
}
36043647

3648+
if (part === '' || part === '.') {
3649+
current = previous;
3650+
return process.nextTick(LOOP);
3651+
}
3652+
if (part === '..') {
3653+
base = pathModule.dirname(previous);
3654+
current = base;
3655+
if (current[current.length - 1] !== CHAR_FORWARD_SLASH &&
3656+
current[current.length - 1] !== CHAR_BACKWARD_SLASH) {
3657+
current += pathModule.sep;
3658+
}
3659+
return process.nextTick(LOOP);
3660+
}
36053661
// Continue if not a symlink, break if a pipe/socket
36063662
if (knownHard.has(base)) {
36073663
if (reachedPipeOrSocket) {
3608-
return callback(null, encodeRealpathResult(p, options));
3664+
return callback(null, encodeRealpathResult(pathModule.resolve(p), options));
36093665
}
36103666
return process.nextTick(LOOP);
36113667
}
@@ -3649,12 +3705,12 @@ function realpath(p, options, callback) {
36493705
function gotTarget(err, target) {
36503706
if (err) return callback(err);
36513707

3652-
gotResolvedLink(pathModule.resolve(previous, target));
3708+
gotResolvedLink(joinRealpathLink(previous, target));
36533709
}
36543710

36553711
function gotResolvedLink(resolvedLink) {
36563712
// Resolve the link, then start over
3657-
p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
3713+
p = joinRealpathRemainder(resolvedLink, StringPrototypeSlice(p, pos));
36583714
current = base = splitRoot(p);
36593715
pos = current.length;
36603716

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Flags: --expose-internals
2+
3+
'use strict';
4+
5+
const common = require('../common');
6+
const assert = require('assert');
7+
const { spawnSync } = require('child_process');
8+
const fs = require('fs');
9+
const { realpathCacheKey } = require('internal/fs/utils');
10+
const path = require('path');
11+
const tmpdir = require('../common/tmpdir');
12+
13+
if (process.argv[2] === 'child') {
14+
if (process.argv[4] === 'callback') {
15+
fs.realpath(process.argv[3], common.mustSucceed((result) => {
16+
process.stdout.write(result);
17+
}));
18+
} else {
19+
process.stdout.write(fs.realpathSync(process.argv[3]));
20+
}
21+
} else {
22+
// Windows applies `..` in symlink targets lexically, so `d -> c\..\d` points
23+
// at itself there and the walk fails with ELOOP, like the operating system.
24+
if (common.isWindows)
25+
common.skip('symlink targets are normalized lexically on Windows');
26+
if (!common.canCreateSymLink())
27+
common.skip('insufficient privileges');
28+
29+
tmpdir.refresh();
30+
31+
// Reproduce a symlink target containing `..` after another symlink:
32+
// c -> a/b/c
33+
// d -> c/../d -> a/b/d
34+
// The cache fixture also creates a separate d to ensure that c/../d stays
35+
// distinct from the lexically normalized path d.
36+
const target = tmpdir.resolve('a', 'b', 'd');
37+
fs.mkdirSync(tmpdir.resolve('a', 'b', 'c'), { recursive: true });
38+
fs.mkdirSync(target);
39+
fs.symlinkSync(path.join('a', 'b', 'c'), tmpdir.resolve('c'), 'dir');
40+
fs.symlinkSync(`c${path.sep}..${path.sep}d`, tmpdir.resolve('d'), 'dir');
41+
42+
const cacheRoot = tmpdir.resolve('cache');
43+
const cacheTarget = path.join(cacheRoot, 'a', 'b', 'd');
44+
const cachePlain = path.join(cacheRoot, 'd');
45+
fs.mkdirSync(path.join(cacheRoot, 'a', 'b', 'c'), { recursive: true });
46+
fs.mkdirSync(cacheTarget);
47+
fs.mkdirSync(cachePlain);
48+
fs.symlinkSync(path.join('a', 'b', 'c'), path.join(cacheRoot, 'c'), 'dir');
49+
const cacheDotted = `${cacheRoot}${path.sep}c${path.sep}..${path.sep}d`;
50+
const cacheExpected = fs.realpathSync(cacheTarget);
51+
const plainExpected = fs.realpathSync(cachePlain);
52+
53+
function assertCacheIsolation(first, firstExpected, second, secondExpected) {
54+
const cache = new Map();
55+
const options = { [realpathCacheKey]: cache };
56+
assert.strictEqual(fs.realpathSync(first, options), firstExpected);
57+
assert.strictEqual(fs.realpathSync(second, options), secondExpected);
58+
}
59+
60+
assertCacheIsolation(cacheDotted, cacheExpected, cachePlain, plainExpected);
61+
assertCacheIsolation(cachePlain, plainExpected, cacheDotted, cacheExpected);
62+
63+
// Run realpathSync in a child because the regression does not return.
64+
const result = spawnSync(process.execPath, [
65+
'--expose-internals',
66+
__filename,
67+
'child',
68+
tmpdir.resolve('d'),
69+
], {
70+
encoding: 'utf8',
71+
timeout: common.platformTimeout(5000),
72+
});
73+
74+
assert.ifError(result.error);
75+
assert.strictEqual(result.status, 0, result.stderr);
76+
assert.strictEqual(result.stdout, target);
77+
78+
const directInput = `c${path.sep}..${path.sep}d`;
79+
const directResult = spawnSync(process.execPath, [
80+
'--expose-internals',
81+
__filename,
82+
'child',
83+
directInput,
84+
], {
85+
encoding: 'utf8',
86+
cwd: tmpdir.path,
87+
timeout: common.platformTimeout(5000),
88+
});
89+
90+
assert.ifError(directResult.error);
91+
assert.strictEqual(directResult.status, 0, directResult.stderr);
92+
assert.strictEqual(directResult.stdout, target);
93+
94+
const callbackResult = spawnSync(process.execPath, [
95+
'--expose-internals',
96+
__filename,
97+
'child',
98+
tmpdir.resolve('d'),
99+
'callback',
100+
], {
101+
encoding: 'utf8',
102+
timeout: common.platformTimeout(5000),
103+
});
104+
105+
assert.ifError(callbackResult.error);
106+
assert.strictEqual(callbackResult.status, 0, callbackResult.stderr);
107+
assert.strictEqual(callbackResult.stdout, target);
108+
}

0 commit comments

Comments
 (0)