Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 67 additions & 11 deletions lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3339,6 +3339,25 @@ if (isWindows) {
};
}

// realpath(3) applies `..` to the resolved parent, so on POSIX `.` and `..`
// are kept in the path while walking it. Windows applies `..` lexically, both
// in the path being opened and in symlink targets, so the path and the link
// targets are normalized up front there, matching `fs.realpathSync.native()`.
function toRealpathInput(p) {
if (isWindows) return pathModule.resolve(p);
return pathModule.isAbsolute(p) ? p : `${pathModule.resolve()}/${p}`;
}

function joinRealpathLink(previous, linkTarget) {
if (isWindows) return pathModule.resolve(previous, linkTarget);
return pathModule.isAbsolute(linkTarget) ? linkTarget : `${previous}${linkTarget}`;
}

function joinRealpathRemainder(resolvedLink, remainder) {
if (isWindows) return pathModule.resolve(resolvedLink, remainder);
return remainder.length === 0 ? resolvedLink : `${resolvedLink}/${remainder}`;
}

/**
* Returns the resolved pathname.
* @param {string | Buffer | URL} p
Expand All @@ -3357,7 +3376,7 @@ function realpathSync(p, options) {
p += '';
}
validatePath(p);
p = pathModule.resolve(p);
p = toRealpathInput(p);

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

const cache = options[realpathCacheKey];
const maybeCachedResult = cache?.get(p);
const original = p;
const maybeCachedResult = cache?.get(original);
if (maybeCachedResult) {
return maybeCachedResult;
}

const seenLinks = new SafeMap();
const knownHard = new SafeSet();
const original = p;
// Whether the symlink this walk resolved last pointed at a pipe or a
// socket, which is where the walk stops. It cannot be read back from the
// shared stat buffer, which holds the last stat made anywhere in the
Expand Down Expand Up @@ -3414,6 +3433,9 @@ function realpathSync(p, options) {
while (pos < p.length) {
// find the next part
const result = nextPart(p, pos);
const part = result === -1 ?
StringPrototypeSlice(p, pos) :
StringPrototypeSlice(p, pos, result);
previous = current;
if (result === -1) {
const last = StringPrototypeSlice(p, pos);
Expand All @@ -3426,6 +3448,19 @@ function realpathSync(p, options) {
pos = result + 1;
}

if (part === '' || part === '.') {
current = previous;
continue;
}
if (part === '..') {
base = pathModule.dirname(previous);
current = base;
if (current[current.length - 1] !== CHAR_FORWARD_SLASH &&
current[current.length - 1] !== CHAR_BACKWARD_SLASH) {
current += pathModule.sep;
}
continue;
}
// Continue if not a symlink, break if a pipe/socket
if (knownHard.has(base) || cache?.get(base) === base) {
if (reachedPipeOrSocket) {
Expand Down Expand Up @@ -3471,14 +3506,18 @@ function realpathSync(p, options) {
isFileType(targetStats, S_IFSOCK);
linkTarget = binding.readlink(base, undefined);
}
resolvedLink = pathModule.resolve(previous, linkTarget);
resolvedLink = joinRealpathLink(previous, linkTarget);

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

// Resolve the link, then start over
p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
p = joinRealpathRemainder(resolvedLink, StringPrototypeSlice(p, pos));

// Skip over roots
current = base = splitRoot(p);
Expand All @@ -3495,6 +3534,7 @@ function realpathSync(p, options) {
}
}

p = pathModule.resolve(p);
cache?.set(original, p);
return encodeRealpathResult(p, options);
}
Expand Down Expand Up @@ -3547,7 +3587,7 @@ function realpath(p, options, callback) {
p += '';
}
validatePath(p);
p = pathModule.resolve(p);
p = toRealpathInput(p);

const seenLinks = new SafeMap();
const knownHard = new SafeSet();
Expand Down Expand Up @@ -3585,11 +3625,14 @@ function realpath(p, options, callback) {
function LOOP() {
// Stop if scanned past end of path
if (pos >= p.length) {
return callback(null, encodeRealpathResult(p, options));
return callback(null, encodeRealpathResult(pathModule.resolve(p), options));
}

// find the next part
const result = nextPart(p, pos);
const part = result === -1 ?
StringPrototypeSlice(p, pos) :
StringPrototypeSlice(p, pos, result);
previous = current;
if (result === -1) {
const last = StringPrototypeSlice(p, pos);
Expand All @@ -3602,10 +3645,23 @@ function realpath(p, options, callback) {
pos = result + 1;
}

if (part === '' || part === '.') {
current = previous;
return process.nextTick(LOOP);
}
if (part === '..') {
base = pathModule.dirname(previous);
current = base;
if (current[current.length - 1] !== CHAR_FORWARD_SLASH &&
current[current.length - 1] !== CHAR_BACKWARD_SLASH) {
current += pathModule.sep;
}
return process.nextTick(LOOP);
}
// Continue if not a symlink, break if a pipe/socket
if (knownHard.has(base)) {
if (reachedPipeOrSocket) {
return callback(null, encodeRealpathResult(p, options));
return callback(null, encodeRealpathResult(pathModule.resolve(p), options));
}
return process.nextTick(LOOP);
}
Expand Down Expand Up @@ -3649,12 +3705,12 @@ function realpath(p, options, callback) {
function gotTarget(err, target) {
if (err) return callback(err);

gotResolvedLink(pathModule.resolve(previous, target));
gotResolvedLink(joinRealpathLink(previous, target));
}

function gotResolvedLink(resolvedLink) {
// Resolve the link, then start over
p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
p = joinRealpathRemainder(resolvedLink, StringPrototypeSlice(p, pos));
current = base = splitRoot(p);
pos = current.length;

Expand Down
108 changes: 108 additions & 0 deletions test/parallel/test-fs-realpath-symlink-dot-dot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Flags: --expose-internals

'use strict';

const common = require('../common');
const assert = require('assert');
const { spawnSync } = require('child_process');
const fs = require('fs');
const { realpathCacheKey } = require('internal/fs/utils');
const path = require('path');
const tmpdir = require('../common/tmpdir');

if (process.argv[2] === 'child') {
if (process.argv[4] === 'callback') {
fs.realpath(process.argv[3], common.mustSucceed((result) => {
process.stdout.write(result);
}));
} else {
process.stdout.write(fs.realpathSync(process.argv[3]));
}
} else {
// Windows applies `..` in symlink targets lexically, so `d -> c\..\d` points
// at itself there and the walk fails with ELOOP, like the operating system.
if (common.isWindows)
common.skip('symlink targets are normalized lexically on Windows');
if (!common.canCreateSymLink())
common.skip('insufficient privileges');

tmpdir.refresh();

// Reproduce a symlink target containing `..` after another symlink:
// c -> a/b/c
// d -> c/../d -> a/b/d
// The cache fixture also creates a separate d to ensure that c/../d stays
// distinct from the lexically normalized path d.
const target = tmpdir.resolve('a', 'b', 'd');
fs.mkdirSync(tmpdir.resolve('a', 'b', 'c'), { recursive: true });
fs.mkdirSync(target);
fs.symlinkSync(path.join('a', 'b', 'c'), tmpdir.resolve('c'), 'dir');
fs.symlinkSync(`c${path.sep}..${path.sep}d`, tmpdir.resolve('d'), 'dir');

const cacheRoot = tmpdir.resolve('cache');
const cacheTarget = path.join(cacheRoot, 'a', 'b', 'd');
const cachePlain = path.join(cacheRoot, 'd');
fs.mkdirSync(path.join(cacheRoot, 'a', 'b', 'c'), { recursive: true });
fs.mkdirSync(cacheTarget);
fs.mkdirSync(cachePlain);
fs.symlinkSync(path.join('a', 'b', 'c'), path.join(cacheRoot, 'c'), 'dir');
const cacheDotted = `${cacheRoot}${path.sep}c${path.sep}..${path.sep}d`;
const cacheExpected = fs.realpathSync(cacheTarget);
const plainExpected = fs.realpathSync(cachePlain);

function assertCacheIsolation(first, firstExpected, second, secondExpected) {
const cache = new Map();
const options = { [realpathCacheKey]: cache };
assert.strictEqual(fs.realpathSync(first, options), firstExpected);
assert.strictEqual(fs.realpathSync(second, options), secondExpected);
}

assertCacheIsolation(cacheDotted, cacheExpected, cachePlain, plainExpected);
assertCacheIsolation(cachePlain, plainExpected, cacheDotted, cacheExpected);

// Run realpathSync in a child because the regression does not return.
const result = spawnSync(process.execPath, [
'--expose-internals',
__filename,
'child',
tmpdir.resolve('d'),
], {
encoding: 'utf8',
timeout: common.platformTimeout(5000),
});

assert.ifError(result.error);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, target);

const directInput = `c${path.sep}..${path.sep}d`;
const directResult = spawnSync(process.execPath, [
'--expose-internals',
__filename,
'child',
directInput,
], {
encoding: 'utf8',
cwd: tmpdir.path,
timeout: common.platformTimeout(5000),
});

assert.ifError(directResult.error);
assert.strictEqual(directResult.status, 0, directResult.stderr);
assert.strictEqual(directResult.stdout, target);

const callbackResult = spawnSync(process.execPath, [
'--expose-internals',
__filename,
'child',
tmpdir.resolve('d'),
'callback',
], {
encoding: 'utf8',
timeout: common.platformTimeout(5000),
});

assert.ifError(callbackResult.error);
assert.strictEqual(callbackResult.status, 0, callbackResult.stderr);
assert.strictEqual(callbackResult.stdout, target);
}
Loading