Skip to content

[node-core-library] Fix semantics of RealNodeModulePath #5042

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@rushstack/node-core-library",
"comment": "Fix handling of trailing slashes and relative paths in RealNodeModulePath to match semantics of `fs.realpathSync.native`.",
"type": "patch"
}
],
"packageName": "@rushstack/node-core-library"
}
4 changes: 2 additions & 2 deletions common/reviews/api/node-core-library.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,9 +609,9 @@ export interface IReadLinesFromIterableOptions {
// @public
export interface IRealNodeModulePathResolverOptions {
// (undocumented)
fs: Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>;
fs?: Partial<Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>>;
// (undocumented)
path: Pick<typeof nodePath, 'isAbsolute' | 'normalize' | 'resolve' | 'sep'>;
path?: Partial<Pick<typeof nodePath, 'isAbsolute' | 'join' | 'resolve' | 'sep'>>;
}

// @public (undocumented)
Expand Down
78 changes: 50 additions & 28 deletions libraries/node-core-library/src/RealNodeModulePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import * as nodePath from 'path';
* @public
*/
export interface IRealNodeModulePathResolverOptions {
fs: Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>;
path: Pick<typeof nodePath, 'isAbsolute' | 'normalize' | 'resolve' | 'sep'>;
fs?: Partial<Pick<typeof nodeFs, 'lstatSync' | 'readlinkSync'>>;
path?: Partial<Pick<typeof nodePath, 'isAbsolute' | 'join' | 'resolve' | 'sep'>>;
}

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

private readonly _cache: Map<string, string>;
private readonly _fs: IRealNodeModulePathResolverOptions['fs'];

public constructor(
options: IRealNodeModulePathResolverOptions = {
fs: nodeFs,
path: nodePath
}
) {
private readonly _fs: Required<NonNullable<IRealNodeModulePathResolverOptions['fs']>>;
private readonly _path: Required<NonNullable<IRealNodeModulePathResolverOptions['path']>>;

public constructor(options: IRealNodeModulePathResolverOptions = {}) {
const {
fs: { lstatSync = nodeFs.lstatSync, readlinkSync = nodeFs.readlinkSync } = nodeFs,
path: {
isAbsolute = nodePath.isAbsolute,
join = nodePath.join,
resolve = nodePath.resolve,
sep = nodePath.sep
} = nodePath
} = options;
const cache: Map<string, string> = (this._cache = new Map());
const { path, fs } = options;
const { sep: pathSeparator } = path;
this._fs = fs;

const nodeModulesToken: string = `${pathSeparator}node_modules${pathSeparator}`;
this._fs = {
lstatSync,
readlinkSync
};
this._path = {
isAbsolute,
join,
resolve,
sep
};

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

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

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

linkStart = linkEnd;
linkEnd = input.indexOf(pathSeparator, linkStart + 1);
linkEnd = input.indexOf(sep, linkStart + 1);
}

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

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

// Relative path or does not exist
Expand All @@ -102,23 +119,26 @@ export class RealNodeModulePathResolver {
const realpathBeforeNodeModules: string = realNodeModulePathInternal(input.slice(0, nodeModulesIndex));
if (linkTarget) {
// Relative path in symbolic link. Should be resolved relative to real path of base path.
const resolvedTarget: string = path.resolve(
`${realpathBeforeNodeModules}${input.slice(nodeModulesIndex, linkStart)}`,
const resolvedTarget: string = resolve(
realpathBeforeNodeModules,
input.slice(nodeModulesIndex + 1, linkStart),
linkTarget
);
// Cache the result of the combined resolution to avoid the readlink call in subsequent calls
cache.set(linkCandidate, resolvedTarget);
cache.set(resolvedTarget, resolvedTarget);
return `${resolvedTarget}${input.slice(linkEnd)}`;
// Joining to `.` will clean up any extraneous trailing slashes
return join(resolvedTarget, input.slice(linkEnd + 1), '.');
}

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

this.realNodeModulePath = (input: string) => {
return realNodeModulePathInternal(path.normalize(input));
return realNodeModulePathInternal(resolve(input));
};
}

Expand Down Expand Up @@ -146,7 +166,9 @@ export class RealNodeModulePathResolver {
// of an lstat call.
const stat: nodeFs.Stats | undefined = this._fs.lstatSync(link);
if (stat.isSymbolicLink()) {
return this._fs.readlinkSync(link, 'utf8');
// path.join(x, '.') will trim trailing slashes, if applicable
const result: string = this._path.join(this._fs.readlinkSync(link, 'utf8'), '.');
return result;
}
}
}
70 changes: 60 additions & 10 deletions libraries/node-core-library/src/test/RealNodeModulePath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ describe('realNodeModulePath', () => {
resolver.clearCache();
});

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

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

it('should trim a trailing slash from the input path if it is not a symbolic link', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => false } as unknown as fs.Stats);

expect(realNodeModulePath('/foo/node_modules/foo/')).toBe('/foo/node_modules/foo');

expect(mocklstatSync).toHaveBeenCalledWith('/foo/node_modules/foo');
expect(mocklstatSync).toHaveBeenCalledTimes(1);
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
});

it('Should handle absolute link targets', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
mockReadlinkSync.mockReturnValueOnce('/link/target');
Expand All @@ -66,12 +76,25 @@ describe('realNodeModulePath', () => {
expect(mockReadlinkSync).toHaveBeenCalledTimes(1);
});

it('Should trim trailing slash from absolute link targets', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
mockReadlinkSync.mockReturnValueOnce('/link/target/');

expect(realNodeModulePath('/foo/node_modules/link/bar')).toBe('/link/target/bar');

expect(mocklstatSync).toHaveBeenCalledWith('/foo/node_modules/link');
expect(mocklstatSync).toHaveBeenCalledTimes(1);
expect(mockReadlinkSync).toHaveBeenCalledWith('/foo/node_modules/link', 'utf8');
expect(mockReadlinkSync).toHaveBeenCalledTimes(1);
});

it('Caches resolved symlinks', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
mockReadlinkSync.mockReturnValueOnce('/link/target');

expect(realNodeModulePath('/foo/node_modules/link')).toBe('/link/target');
expect(realNodeModulePath('/foo/node_modules/link/bar')).toBe('/link/target/bar');
expect(realNodeModulePath('/foo/node_modules/link/')).toBe('/link/target');

expect(mocklstatSync).toHaveBeenCalledWith('/foo/node_modules/link');
expect(mocklstatSync).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -155,22 +178,27 @@ describe('realNodeModulePath', () => {
resolver.clearCache();
});

it('should return the input path if it does not contain node_modules', () => {
for (const input of ['C:\\foo\\bar', 'C:\\', 'ab', '..\\foo\\bar\\baz']) {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);

it('should return the input path if it is absolute and does not contain node_modules', () => {
for (const input of ['C:\\foo\\bar', 'C:\\']) {
expect(realNodeModulePath(input)).toBe(input);

expect(mocklstatSync).not.toHaveBeenCalled();
expect(mockReadlinkSync).not.toHaveBeenCalled();
}
});

it('should return the normalized input path if it does not contain node_modules', () => {
for (const input of ['C:/foo/bar', 'C:/', 'ab', '../foo/bar/baz']) {
it('should trim extra trailing separators from the root', () => {
expect(realNodeModulePath('C:////')).toBe('C:\\');

expect(mocklstatSync).not.toHaveBeenCalled();
expect(mockReadlinkSync).not.toHaveBeenCalled();
});

it('should return the resolved input path if it is absolute and does not contain node_modules', () => {
for (const input of ['C:/foo/bar', 'C:/', 'ab', '../b/c/d']) {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);

expect(realNodeModulePath(input)).toBe(path.win32.normalize(input));
expect(realNodeModulePath(input)).toBe(path.win32.resolve(input));

expect(mocklstatSync).not.toHaveBeenCalled();
expect(mockReadlinkSync).not.toHaveBeenCalled();
Expand All @@ -187,11 +215,33 @@ describe('realNodeModulePath', () => {
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
});

it('Should trim a trailing path separator if the target is not a symbolic link', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => false } as unknown as fs.Stats);

expect(realNodeModulePath('C:\\foo\\node_modules\\foo\\')).toBe('C:\\foo\\node_modules\\foo');

expect(mocklstatSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\foo');
expect(mocklstatSync).toHaveBeenCalledTimes(1);
expect(mockReadlinkSync).toHaveBeenCalledTimes(0);
});

it('Should handle absolute link targets', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
mockReadlinkSync.mockReturnValueOnce('C:\\link\\target');

expect(realNodeModulePath('C:\\foo\\node_modules\\link')).toBe('C:\\link\\target');
expect(realNodeModulePath('C:\\foo\\node_modules\\link\\relative')).toBe('C:\\link\\target\\relative');

expect(mocklstatSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\link');
expect(mocklstatSync).toHaveBeenCalledTimes(1);
expect(mockReadlinkSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\link', 'utf8');
expect(mockReadlinkSync).toHaveBeenCalledTimes(1);
});

it('Should trim a trailing path separator from an absolute link target', () => {
mocklstatSync.mockReturnValueOnce({ isSymbolicLink: () => true } as unknown as fs.Stats);
mockReadlinkSync.mockReturnValueOnce('C:\\link\\target\\');

expect(realNodeModulePath('C:\\foo\\node_modules\\link\\relative')).toBe('C:\\link\\target\\relative');

expect(mocklstatSync).toHaveBeenCalledWith('C:\\foo\\node_modules\\link');
expect(mocklstatSync).toHaveBeenCalledTimes(1);
Expand Down
Loading