Skip to content
Draft
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
101 changes: 90 additions & 11 deletions lib/fs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3224,6 +3224,20 @@ if (isWindows) {
};
}

function toAbsolutePathPreservingDots(p) {
if (!pathModule.isAbsolute(p)) {
const root = splitRoot(p);
if (isWindows && root.length === 2) {
return `${pathModule.resolve(root)}${pathModule.sep}${StringPrototypeSlice(p, root.length)}`;
}
return `${pathModule.resolve()}${pathModule.sep}${p}`;
}
if (isWindows && splitRoot(p).length === 1) {
return `${pathModule.parse(pathModule.resolve()).root}${StringPrototypeSlice(p, 1)}`;
}
return p;
}

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

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 @@ -3287,6 +3301,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 @@ -3299,6 +3316,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 @@ -3344,14 +3374,30 @@ function realpathSync(p, options) {
isFileType(targetStats, S_IFSOCK);
linkTarget = binding.readlink(base, undefined);
}
resolvedLink = pathModule.resolve(previous, linkTarget);
const linkRoot = splitRoot(linkTarget);
if (pathModule.isAbsolute(linkTarget)) {
resolvedLink = isWindows && linkRoot.length === 1 ?
pathModule.resolve(previous, linkRoot) +
StringPrototypeSlice(linkTarget, linkRoot.length) :
linkTarget;
} else if (isWindows && linkRoot.length === 2) {
resolvedLink = pathModule.resolve(linkRoot) +
pathModule.sep + StringPrototypeSlice(linkTarget, linkRoot.length);
} else {
resolvedLink = `${previous}${linkTarget}`;
}

cache?.set(base, resolvedLink);
if (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));
const remainder = StringPrototypeSlice(p, pos);
p = remainder.length === 0 ?
resolvedLink :
`${resolvedLink}${pathModule.sep}${remainder}`;

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

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

const seenLinks = new SafeMap();
const knownHard = new SafeSet();
Expand Down Expand Up @@ -3457,11 +3504,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 @@ -3474,10 +3524,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 @@ -3521,12 +3584,28 @@ function realpath(p, options, callback) {
function gotTarget(err, target) {
if (err) return callback(err);

gotResolvedLink(pathModule.resolve(previous, target));
const linkRoot = splitRoot(target);
let resolvedLink;
if (pathModule.isAbsolute(target)) {
resolvedLink = isWindows && linkRoot.length === 1 ?
pathModule.resolve(previous, linkRoot) +
StringPrototypeSlice(target, linkRoot.length) :
target;
} else if (isWindows && linkRoot.length === 2) {
resolvedLink = pathModule.resolve(linkRoot) +
pathModule.sep + StringPrototypeSlice(target, linkRoot.length);
} else {
resolvedLink = `${previous}${target}`;
}
gotResolvedLink(resolvedLink);
}

function gotResolvedLink(resolvedLink) {
// Resolve the link, then start over
p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
const remainder = StringPrototypeSlice(p, pos);
p = remainder.length === 0 ?
resolvedLink :
`${resolvedLink}${pathModule.sep}${remainder}`;
current = base = splitRoot(p);
pos = current.length;

Expand Down
104 changes: 104 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,104 @@
// 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 {
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);
}