Skip to content

Commit 5ded71f

Browse files
unstubbableaduh95
authored andcommitted
test: cover realpathSync resolving symlinks after a FIFO stat
While walking a path, `realpathSync` skips the components it already knows are real, and in that branch it reads the shared stat buffer to decide whether the walk has reached a pipe or a socket. That buffer holds the result of the last stat made anywhere in the process rather than the last one made by the walk, so an unrelated stat of a FIFO ends the walk early and the path comes back with its symlinks unresolved. The unresolved path is then written to the cache, so every later resolution repeats it. The walk only takes that branch once the ancestors are established as real, which is the state the module loader's cache is in. The test goes through `require()` to reach it, where the stale read costs a second copy of a module reached through a symlink. Signed-off-by: Hendrik Liebau <mail@hendrik-liebau.de> PR-URL: #65113 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent 8029383 commit 5ded71f

3 files changed

Lines changed: 153 additions & 7 deletions

File tree

lib/fs.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,6 @@ const {
8484

8585
const {
8686
FSReqCallback,
87-
statValues,
8887
} = binding;
8988
const { toPathIfFileURL } = require('internal/url');
9089
const {
@@ -2787,6 +2786,11 @@ function realpathSync(p, options) {
27872786
const seenLinks = new SafeMap();
27882787
const knownHard = new SafeSet();
27892788
const original = p;
2789+
// Whether the symlink this walk resolved last pointed at a pipe or a
2790+
// socket, which is where the walk stops. It cannot be read back from the
2791+
// shared stat buffer, which holds the last stat made anywhere in the
2792+
// process rather than the last one made here.
2793+
let reachedPipeOrSocket = false;
27902794

27912795
// Current character position in p
27922796
let pos;
@@ -2830,8 +2834,7 @@ function realpathSync(p, options) {
28302834

28312835
// Continue if not a symlink, break if a pipe/socket
28322836
if (knownHard.has(base) || cache?.get(base) === base) {
2833-
if (isFileType(statValues, S_IFIFO) ||
2834-
isFileType(statValues, S_IFSOCK)) {
2837+
if (reachedPipeOrSocket) {
28352838
break;
28362839
}
28372840
continue;
@@ -2869,7 +2872,9 @@ function realpathSync(p, options) {
28692872
}
28702873
}
28712874
if (linkTarget === null) {
2872-
binding.stat(base, false, undefined, true);
2875+
const targetStats = binding.stat(base, false, undefined, true);
2876+
reachedPipeOrSocket = isFileType(targetStats, S_IFIFO) ||
2877+
isFileType(targetStats, S_IFSOCK);
28732878
linkTarget = binding.readlink(base, undefined);
28742879
}
28752880
resolvedLink = pathModule.resolve(previous, linkTarget);
@@ -2941,6 +2946,11 @@ function realpath(p, options, callback) {
29412946

29422947
const seenLinks = new SafeMap();
29432948
const knownHard = new SafeSet();
2949+
// Whether the symlink this walk resolved last pointed at a pipe or a
2950+
// socket, which is where the walk stops. It cannot be read back from the
2951+
// shared stat buffer, which holds the last stat made anywhere in the
2952+
// process rather than the last one made here.
2953+
let reachedPipeOrSocket = false;
29442954

29452955
// Current character position in p
29462956
let pos;
@@ -2989,8 +2999,7 @@ function realpath(p, options, callback) {
29892999

29903000
// Continue if not a symlink, break if a pipe/socket
29913001
if (knownHard.has(base)) {
2992-
if (isFileType(statValues, S_IFIFO) ||
2993-
isFileType(statValues, S_IFSOCK)) {
3002+
if (reachedPipeOrSocket) {
29943003
return callback(null, encodeRealpathResult(p, options));
29953004
}
29963005
return process.nextTick(LOOP);
@@ -3020,9 +3029,11 @@ function realpath(p, options, callback) {
30203029
return gotTarget(null, seenLinks.get(id));
30213030
}
30223031
}
3023-
fs.stat(base, (err) => {
3032+
fs.stat(base, (err, targetStats) => {
30243033
if (err) return callback(err);
30253034

3035+
reachedPipeOrSocket = targetStats.isFIFO() || targetStats.isSocket();
3036+
30263037
fs.readlink(base, (err, target) => {
30273038
if (!isWindows) seenLinks.set(id, target);
30283039
gotTarget(err, target);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use strict';
2+
3+
// The async realpath() reads the shared stat buffer the same way realpathSync()
4+
// did, to decide whether the walk has reached a pipe or a socket. The walk's
5+
// own fs.stat() does leave the right value there, but it is not read until
6+
// after fs.readlink() and a process.nextTick(), and any stat completing in that
7+
// window replaces it.
8+
//
9+
// Truncating the walk only costs something when a second symlink follows the
10+
// one being resolved, so the path used here has two.
11+
12+
const common = require('../common');
13+
14+
if (common.isWindows)
15+
common.skip('no mkfifo on Windows');
16+
17+
const assert = require('assert');
18+
const fs = require('fs');
19+
const path = require('path');
20+
const { execFileSync } = require('child_process');
21+
const tmpdir = require('../common/tmpdir');
22+
23+
tmpdir.refresh();
24+
25+
const real = tmpdir.resolve('real');
26+
const pkg = tmpdir.resolve('pkg');
27+
const fifo = tmpdir.resolve('fifo');
28+
29+
fs.mkdirSync(real);
30+
fs.mkdirSync(pkg);
31+
fs.writeFileSync(path.join(real, 'index.js'), '');
32+
fs.symlinkSync(path.join('..', 'real'), path.join(pkg, 'sub'));
33+
fs.symlinkSync('pkg', tmpdir.resolve('link'));
34+
execFileSync('mkfifo', [fifo]);
35+
36+
const throughLinks = tmpdir.resolve('link', 'sub', 'index.js');
37+
const expected = path.join(real, 'index.js');
38+
39+
// Keep stats of the FIFO completing for as long as the walk runs, so that one
40+
// of them lands in the buffer during the window.
41+
let settled = false;
42+
(function statFifo() {
43+
if (settled) return;
44+
fs.stat(fifo, statFifo);
45+
})();
46+
47+
let error;
48+
let resolvedPath;
49+
50+
fs.realpath(throughLinks, common.mustCall((err, resolved) => {
51+
settled = true;
52+
error = err;
53+
resolvedPath = resolved;
54+
}));
55+
56+
// Asserted on exit rather than in the callback. An assertion that fails inside
57+
// this callback is lost: it does not reach an `uncaughtException` handler and
58+
// the process still exits 0, so the test would pass over the bug it covers.
59+
process.on('exit', () => {
60+
assert.ifError(error);
61+
assert.strictEqual(resolvedPath, expected);
62+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Flags: --expose-internals
2+
'use strict';
3+
4+
// Resolving a path must not depend on what was stat'ed before it.
5+
//
6+
// While walking a path, realpath skips the components it already knows are
7+
// real, and in that branch it consulted the shared stat buffer to decide
8+
// whether the walk had reached a pipe or a socket. That buffer holds the result
9+
// of the last stat made anywhere in the process, so an unrelated stat of a FIFO
10+
// made the walk stop early and hand back the path with its symlinks unresolved.
11+
// The unresolved path is then cached, so every later resolution repeats it.
12+
13+
const common = require('../common');
14+
15+
if (common.isWindows)
16+
common.skip('no mkfifo on Windows');
17+
18+
const assert = require('assert');
19+
const fs = require('fs');
20+
const path = require('path');
21+
const { execFileSync } = require('child_process');
22+
const { realpathCacheKey } = require('internal/fs/utils');
23+
const tmpdir = require('../common/tmpdir');
24+
25+
tmpdir.refresh();
26+
27+
const pkg = tmpdir.resolve('pkg');
28+
const link = tmpdir.resolve('pkg-link');
29+
const fifo = tmpdir.resolve('fifo');
30+
31+
fs.mkdirSync(pkg);
32+
fs.writeFileSync(path.join(pkg, 'index.js'), 'module.exports = {};\n');
33+
fs.writeFileSync(tmpdir.resolve('warm.js'), 'module.exports = {};\n');
34+
fs.symlinkSync('pkg', link);
35+
execFileSync('mkfifo', [fifo]);
36+
37+
const throughLink = path.join(link, 'index.js');
38+
const throughReal = path.join(pkg, 'index.js');
39+
40+
// The walk only skips a component once something has established it as real. A
41+
// cache carrying the ancestors is that state, and it is the state the module
42+
// loader's own cache is in after it has resolved anything else under the
43+
// directory.
44+
function ancestorCache() {
45+
const cache = new Map();
46+
let dir = '';
47+
for (const part of tmpdir.path.split(path.sep).slice(1)) {
48+
dir += path.sep + part;
49+
cache.set(dir, dir);
50+
}
51+
return cache;
52+
}
53+
54+
fs.statSync(path.join(pkg, 'index.js'));
55+
assert.strictEqual(
56+
fs.realpathSync(throughLink, { [realpathCacheKey]: ancestorCache() }),
57+
throughReal,
58+
);
59+
60+
fs.statSync(fifo);
61+
assert.strictEqual(
62+
fs.realpathSync(throughLink, { [realpathCacheKey]: ancestorCache() }),
63+
throughReal,
64+
);
65+
66+
// What the stale read costs through the module loader, whose cache puts the
67+
// walk in that same state: the symlink stays unresolved, so the file is loaded
68+
// a second time under a second name.
69+
require(tmpdir.resolve('warm.js'));
70+
fs.statSync(fifo);
71+
72+
assert.strictEqual(require.resolve(throughLink), throughReal);
73+
assert.strictEqual(require(throughLink), require(throughReal));

0 commit comments

Comments
 (0)