Skip to content

Commit 2e2ac57

Browse files
authored
[node-core-library] Fix semantics of RealNodeModulePath (#5042)
* [node-core-library] Fix semantics of RealNodeModulePath * Add tests for relative paths * Use `path.join` * Optionalize, use path.join instead of parse/format * Fix parameter optionality --------- Co-authored-by: David Michon <[email protected]>
1 parent 5a17143 commit 2e2ac57

File tree

4 files changed

+122
-40
lines changed

4 files changed

+122
-40
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@rushstack/node-core-library",
5+
"comment": "Fix handling of trailing slashes and relative paths in RealNodeModulePath to match semantics of `fs.realpathSync.native`.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@rushstack/node-core-library"
10+
}

common/reviews/api/node-core-library.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,9 +609,9 @@ export interface IReadLinesFromIterableOptions {
609609
// @public
610610
export interface IRealNodeModulePathResolverOptions {
611611
// (undocumented)
612-
fs: Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>;
612+
fs?: Partial<Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>>;
613613
// (undocumented)
614-
path: Pick<typeof nodePath, 'isAbsolute' | 'normalize' | 'resolve' | 'sep'>;
614+
path?: Partial<Pick<typeof nodePath, 'isAbsolute' | 'join' | 'resolve' | 'sep'>>;
615615
}
616616

617617
// @public (undocumented)

libraries/node-core-library/src/RealNodeModulePath.ts

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import * as nodePath from 'path';
99
* @public
1010
*/
1111
export interface IRealNodeModulePathResolverOptions {
12-
fs: Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>;
13-
path: Pick<typeof nodePath, 'isAbsolute' | 'normalize' | 'resolve' | 'sep'>;
12+
fs?: Partial<Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>>;
13+
path?: Partial<Pick<typeof nodePath, 'isAbsolute' | 'join' | 'resolve' | 'sep'>>;
1414
}
1515

1616
/**
@@ -38,22 +38,33 @@ export class RealNodeModulePathResolver {
3838
public readonly realNodeModulePath: (input: string) => string;
3939

4040
private readonly _cache: Map<string, string>;
41-
private readonly _fs: IRealNodeModulePathResolverOptions['fs'];
42-
43-
public constructor(
44-
options: IRealNodeModulePathResolverOptions = {
45-
fs: nodeFs,
46-
path: nodePath
47-
}
48-
) {
41+
private readonly _fs: Required<NonNullable<IRealNodeModulePathResolverOptions['fs']>>;
42+
private readonly _path: Required<NonNullable<IRealNodeModulePathResolverOptions['path']>>;
43+
44+
public constructor(options: IRealNodeModulePathResolverOptions = {}) {
45+
const {
46+
fs: { lstatSync = nodeFs.lstatSync, readlinkSync = nodeFs.readlinkSync } = nodeFs,
47+
path: {
48+
isAbsolute = nodePath.isAbsolute,
49+
join = nodePath.join,
50+
resolve = nodePath.resolve,
51+
sep = nodePath.sep
52+
} = nodePath
53+
} = options;
4954
const cache: Map<string, string> = (this._cache = new Map());
50-
const { path, fs } = options;
51-
const { sep: pathSeparator } = path;
52-
this._fs = fs;
53-
54-
const nodeModulesToken: string = `${pathSeparator}node_modules${pathSeparator}`;
55+
this._fs = {
56+
lstatSync,
57+
readlinkSync
58+
};
59+
this._path = {
60+
isAbsolute,
61+
join,
62+
resolve,
63+
sep
64+
};
5565

56-
const tryReadLink: (link: string) => string | undefined = this._tryReadLink.bind(this);
66+
const nodeModulesToken: string = `${sep}node_modules${sep}`;
67+
const self: this = this;
5768

5869
function realNodeModulePathInternal(input: string): string {
5970
// Find the last node_modules path segment
@@ -65,19 +76,24 @@ export class RealNodeModulePathResolver {
6576

6677
// First assume that the next path segment after node_modules is a symlink
6778
let linkStart: number = nodeModulesIndex + nodeModulesToken.length - 1;
68-
let linkEnd: number = input.indexOf(pathSeparator, linkStart + 1);
79+
let linkEnd: number = input.indexOf(sep, linkStart + 1);
6980
// If the path segment starts with a '@', then it is a scoped package
7081
const isScoped: boolean = input.charAt(linkStart + 1) === '@';
7182
if (isScoped) {
7283
// For a scoped package, the scope is an ordinary directory, so we need to find the next path segment
7384
if (linkEnd < 0) {
7485
// Symlink missing, so see if anything before the last node_modules needs resolving,
7586
// and preserve the rest of the path
76-
return `${realNodeModulePathInternal(input.slice(0, nodeModulesIndex))}${input.slice(nodeModulesIndex)}`;
87+
return join(
88+
realNodeModulePathInternal(input.slice(0, nodeModulesIndex)),
89+
input.slice(nodeModulesIndex + 1),
90+
// Joining to `.` will clean up any extraneous trailing slashes
91+
'.'
92+
);
7793
}
7894

7995
linkStart = linkEnd;
80-
linkEnd = input.indexOf(pathSeparator, linkStart + 1);
96+
linkEnd = input.indexOf(sep, linkStart + 1);
8197
}
8298

8399
// No trailing separator, so the link is the last path segment
@@ -87,13 +103,14 @@ export class RealNodeModulePathResolver {
87103

88104
const linkCandidate: string = input.slice(0, linkEnd);
89105
// Check if the link is a symlink
90-
const linkTarget: string | undefined = tryReadLink(linkCandidate);
91-
if (linkTarget && path.isAbsolute(linkTarget)) {
106+
const linkTarget: string | undefined = self._tryReadLink(linkCandidate);
107+
if (linkTarget && isAbsolute(linkTarget)) {
92108
// Absolute path, combine the link target with any remaining path segments
93109
// Cache the resolution to avoid the readlink call in subsequent calls
94110
cache.set(linkCandidate, linkTarget);
95111
cache.set(linkTarget, linkTarget);
96-
return `${linkTarget}${input.slice(linkEnd)}`;
112+
// Joining to `.` will clean up any extraneous trailing slashes
113+
return join(linkTarget, input.slice(linkEnd + 1), '.');
97114
}
98115

99116
// Relative path or does not exist
@@ -102,23 +119,26 @@ export class RealNodeModulePathResolver {
102119
const realpathBeforeNodeModules: string = realNodeModulePathInternal(input.slice(0, nodeModulesIndex));
103120
if (linkTarget) {
104121
// Relative path in symbolic link. Should be resolved relative to real path of base path.
105-
const resolvedTarget: string = path.resolve(
106-
`${realpathBeforeNodeModules}${input.slice(nodeModulesIndex, linkStart)}`,
122+
const resolvedTarget: string = resolve(
123+
realpathBeforeNodeModules,
124+
input.slice(nodeModulesIndex + 1, linkStart),
107125
linkTarget
108126
);
109127
// Cache the result of the combined resolution to avoid the readlink call in subsequent calls
110128
cache.set(linkCandidate, resolvedTarget);
111129
cache.set(resolvedTarget, resolvedTarget);
112-
return `${resolvedTarget}${input.slice(linkEnd)}`;
130+
// Joining to `.` will clean up any extraneous trailing slashes
131+
return join(resolvedTarget, input.slice(linkEnd + 1), '.');
113132
}
114133

115134
// No symlink, so just return the real path before the last node_modules combined with the
116135
// subsequent path segments
117-
return `${realpathBeforeNodeModules}${input.slice(nodeModulesIndex)}`;
136+
// Joining to `.` will clean up any extraneous trailing slashes
137+
return join(realpathBeforeNodeModules, input.slice(nodeModulesIndex + 1), '.');
118138
}
119139

120140
this.realNodeModulePath = (input: string) => {
121-
return realNodeModulePathInternal(path.normalize(input));
141+
return realNodeModulePathInternal(resolve(input));
122142
};
123143
}
124144

@@ -146,7 +166,9 @@ export class RealNodeModulePathResolver {
146166
// of an lstat call.
147167
const stat: nodeFs.Stats | undefined = this._fs.lstatSync(link);
148168
if (stat.isSymbolicLink()) {
149-
return this._fs.readlinkSync(link, 'utf8');
169+
// path.join(x, '.') will trim trailing slashes, if applicable
170+
const result: string = this._path.join(this._fs.readlinkSync(link, 'utf8'), '.');
171+
return result;
150172
}
151173
}
152174
}

libraries/node-core-library/src/test/RealNodeModulePath.test.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ describe('realNodeModulePath', () => {
3535
resolver.clearCache();
3636
});
3737

38-
it('should return the input path if it does not contain node_modules', () => {
39-
for (const input of ['/foo/bar', '/', 'ab', '../foo/bar/baz']) {
38+
it('should return the input path if it is absolute and does not contain node_modules', () => {
39+
for (const input of ['/foo/bar', '/']) {
4040
expect(realNodeModulePath(input)).toBe(input);
4141

4242
expect(mocklstatSync).not.toHaveBeenCalled();
@@ -54,6 +54,16 @@ describe('realNodeModulePath', () => {
5454
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
5555
});
5656

57+
it('should trim a trailing slash from the input path if it is not a symbolic link', () => {
58+
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => false } as unknown as fs.Stats);
59+
60+
expect(realNodeModulePath('/foo/node_modules/foo/')).toBe('/foo/node_modules/foo');
61+
62+
expect(mocklstatSync).toHaveBeenCalledWith('/foo/node_modules/foo');
63+
expect(mocklstatSync).toHaveBeenCalledTimes(1);
64+
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
65+
});
66+
5767
it('Should handle absolute link targets', () => {
5868
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
5969
mockReadlinkSync.mockReturnValueOnce('/link/target');
@@ -66,12 +76,25 @@ describe('realNodeModulePath', () => {
6676
expect(mockReadlinkSync).toHaveBeenCalledTimes(1);
6777
});
6878

79+
it('Should trim trailing slash from absolute link targets', () => {
80+
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
81+
mockReadlinkSync.mockReturnValueOnce('/link/target/');
82+
83+
expect(realNodeModulePath('/foo/node_modules/link/bar')).toBe('/link/target/bar');
84+
85+
expect(mocklstatSync).toHaveBeenCalledWith('/foo/node_modules/link');
86+
expect(mocklstatSync).toHaveBeenCalledTimes(1);
87+
expect(mockReadlinkSync).toHaveBeenCalledWith('/foo/node_modules/link', 'utf8');
88+
expect(mockReadlinkSync).toHaveBeenCalledTimes(1);
89+
});
90+
6991
it('Caches resolved symlinks', () => {
7092
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
7193
mockReadlinkSync.mockReturnValueOnce('/link/target');
7294

7395
expect(realNodeModulePath('/foo/node_modules/link')).toBe('/link/target');
7496
expect(realNodeModulePath('/foo/node_modules/link/bar')).toBe('/link/target/bar');
97+
expect(realNodeModulePath('/foo/node_modules/link/')).toBe('/link/target');
7598

7699
expect(mocklstatSync).toHaveBeenCalledWith('/foo/node_modules/link');
77100
expect(mocklstatSync).toHaveBeenCalledTimes(1);
@@ -155,22 +178,27 @@ describe('realNodeModulePath', () => {
155178
resolver.clearCache();
156179
});
157180

158-
it('should return the input path if it does not contain node_modules', () => {
159-
for (const input of ['C:\\foo\\bar', 'C:\\', 'ab', '..\\foo\\bar\\baz']) {
160-
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
161-
181+
it('should return the input path if it is absolute and does not contain node_modules', () => {
182+
for (const input of ['C:\\foo\\bar', 'C:\\']) {
162183
expect(realNodeModulePath(input)).toBe(input);
163184

164185
expect(mocklstatSync).not.toHaveBeenCalled();
165186
expect(mockReadlinkSync).not.toHaveBeenCalled();
166187
}
167188
});
168189

169-
it('should return the normalized input path if it does not contain node_modules', () => {
170-
for (const input of ['C:/foo/bar', 'C:/', 'ab', '../foo/bar/baz']) {
190+
it('should trim extra trailing separators from the root', () => {
191+
expect(realNodeModulePath('C:////')).toBe('C:\\');
192+
193+
expect(mocklstatSync).not.toHaveBeenCalled();
194+
expect(mockReadlinkSync).not.toHaveBeenCalled();
195+
});
196+
197+
it('should return the resolved input path if it is absolute and does not contain node_modules', () => {
198+
for (const input of ['C:/foo/bar', 'C:/', 'ab', '../b/c/d']) {
171199
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
172200

173-
expect(realNodeModulePath(input)).toBe(path.win32.normalize(input));
201+
expect(realNodeModulePath(input)).toBe(path.win32.resolve(input));
174202

175203
expect(mocklstatSync).not.toHaveBeenCalled();
176204
expect(mockReadlinkSync).not.toHaveBeenCalled();
@@ -187,11 +215,33 @@ describe('realNodeModulePath', () => {
187215
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
188216
});
189217

218+
it('Should trim a trailing path separator if the target is not a symbolic link', () => {
219+
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => false } as unknown as fs.Stats);
220+
221+
expect(realNodeModulePath('C:\\foo\\node_modules\\foo\\')).toBe('C:\\foo\\node_modules\\foo');
222+
223+
expect(mocklstatSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\foo');
224+
expect(mocklstatSync).toHaveBeenCalledTimes(1);
225+
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
226+
});
227+
190228
it('Should handle absolute link targets', () => {
191229
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
192230
mockReadlinkSync.mockReturnValueOnce('C:\\link\\target');
193231

194-
expect(realNodeModulePath('C:\\foo\\node_modules\\link')).toBe('C:\\link\\target');
232+
expect(realNodeModulePath('C:\\foo\\node_modules\\link\\relative')).toBe('C:\\link\\target\\relative');
233+
234+
expect(mocklstatSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\link');
235+
expect(mocklstatSync).toHaveBeenCalledTimes(1);
236+
expect(mockReadlinkSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\link', 'utf8');
237+
expect(mockReadlinkSync).toHaveBeenCalledTimes(1);
238+
});
239+
240+
it('Should trim a trailing path separator from an absolute link target', () => {
241+
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
242+
mockReadlinkSync.mockReturnValueOnce('C:\\link\\target\\');
243+
244+
expect(realNodeModulePath('C:\\foo\\node_modules\\link\\relative')).toBe('C:\\link\\target\\relative');
195245

196246
expect(mocklstatSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\link');
197247
expect(mocklstatSync).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)