forked from microsoft/rushstack
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathSymlinkAnalyzer.ts
216 lines (185 loc) · 7.14 KB
/
SymlinkAnalyzer.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import { FileSystem, type FileSystemStats, Sort } from '@rushstack/node-core-library';
import * as path from 'path';
export interface IPathNodeBase {
kind: 'file' | 'folder' | 'link';
nodePath: string;
linkStats: FileSystemStats;
}
/**
* Represents a file object analyzed by {@link SymlinkAnalyzer}.
*/
export interface IFileNode extends IPathNodeBase {
kind: 'file';
}
/**
* Represents a folder object analyzed by {@link SymlinkAnalyzer}.
*/
export interface IFolderNode extends IPathNodeBase {
kind: 'folder';
}
/**
* Represents a symbolic link analyzed by {@link SymlinkAnalyzer}.
*/
export interface ILinkNode extends IPathNodeBase {
kind: 'link';
/**
* The immediate target that the symlink resolves to.
*/
linkTarget: string;
}
export type PathNode = IFileNode | IFolderNode | ILinkNode;
/**
* Represents a symbolic link.
*
* @public
*/
export interface ILinkInfo {
/**
* The type of link that was encountered.
*/
kind: 'fileLink' | 'folderLink';
/**
* The path to the link, relative to the root of the extractor output folder.
*/
linkPath: string;
/**
* The target that the link points to.
*/
targetPath: string;
}
export interface ISymlinkAnalyzerOptions {
requiredSourceParentPath?: string;
}
export interface IAnalyzePathOptions {
inputPath: string;
preserveLinks?: boolean;
shouldIgnoreExternalLink?: (path: string) => boolean;
}
export class SymlinkAnalyzer {
private readonly _requiredSourceParentPath: string | undefined;
// The directory tree discovered so far
private readonly _nodesByPath: Map<string, PathNode> = new Map<string, PathNode>();
// The symlinks that we encountered while building the directory tree
private readonly _linkInfosByPath: Map<string, ILinkInfo> = new Map<string, ILinkInfo>();
public constructor(options: ISymlinkAnalyzerOptions = {}) {
this._requiredSourceParentPath = options.requiredSourceParentPath;
}
public async analyzePathAsync(
options: IAnalyzePathOptions & { shouldIgnoreExternalLink: (path: string) => boolean }
): Promise<PathNode | undefined>;
public async analyzePathAsync(
options: IAnalyzePathOptions & { shouldIgnoreExternalLink?: never }
): Promise<PathNode>;
public async analyzePathAsync(options: IAnalyzePathOptions): Promise<PathNode | undefined> {
const { inputPath, preserveLinks = false, shouldIgnoreExternalLink } = options;
// First, try to short-circuit the analysis if we've already analyzed this path
const resolvedPath: string = path.resolve(inputPath);
const existingNode: PathNode | undefined = this._nodesByPath.get(resolvedPath);
if (existingNode) {
return existingNode;
}
// Postfix a '/' to the end of the path. This will get trimmed off later, but it
// ensures that the last path component is included in the loop below.
let targetPath: string = `${resolvedPath}${path.sep}`;
let targetPathIndex: number = -1;
let currentNode: PathNode | undefined;
while ((targetPathIndex = targetPath.indexOf(path.sep, targetPathIndex + 1)) >= 0) {
if (targetPathIndex === 0) {
// Edge case for a Unix path like "/folder/file" --> [ "", "folder", "file" ]
continue;
}
const currentPath: string = targetPath.slice(0, targetPathIndex);
currentNode = this._nodesByPath.get(currentPath);
if (currentNode === undefined) {
const linkStats: FileSystemStats = await FileSystem.getLinkStatisticsAsync(currentPath);
if (linkStats.isSymbolicLink()) {
// Link target paths can be relative or absolute, so we need to resolve them
const linkTargetPath: string = await FileSystem.readLinkAsync(currentPath);
const resolvedLinkTargetPath: string = path.resolve(path.dirname(currentPath), linkTargetPath);
// Do a check to make sure that the link target path is not outside the source folder
if (this._requiredSourceParentPath) {
const relativeLinkTargetPath: string = path.relative(
this._requiredSourceParentPath,
resolvedLinkTargetPath
);
if (relativeLinkTargetPath.startsWith('..')) {
// Symlinks that link outside of the source folder may be ignored. Check to see if we
// can ignore this one and if so, return undefined.
if (shouldIgnoreExternalLink?.(currentPath)) {
return undefined;
}
throw new Error(
`Symlink targets not under folder "${this._requiredSourceParentPath}": ` +
`${currentPath} -> ${resolvedLinkTargetPath}`
);
}
}
currentNode = {
kind: 'link',
nodePath: currentPath,
linkStats,
linkTarget: resolvedLinkTargetPath
};
} else if (linkStats.isDirectory()) {
currentNode = {
kind: 'folder',
nodePath: currentPath,
linkStats
};
} else if (linkStats.isFile()) {
currentNode = {
kind: 'file',
nodePath: currentPath,
linkStats
};
} else {
throw new Error('Unknown object type: ' + currentPath);
}
this._nodesByPath.set(currentPath, currentNode);
}
if (!preserveLinks) {
while (currentNode?.kind === 'link') {
const targetNode: PathNode = await this.analyzePathAsync({
inputPath: currentNode.linkTarget,
preserveLinks: true
});
// Have we created an ILinkInfo for this link yet?
if (!this._linkInfosByPath.has(currentNode.nodePath)) {
// Follow any symbolic links to determine whether the final target is a directory
const targetStats: FileSystemStats = await FileSystem.getStatisticsAsync(targetNode.nodePath);
const targetIsDirectory: boolean = targetStats.isDirectory();
const linkInfo: ILinkInfo = {
kind: targetIsDirectory ? 'folderLink' : 'fileLink',
linkPath: currentNode.nodePath,
targetPath: targetNode.nodePath
};
this._linkInfosByPath.set(currentNode.nodePath, linkInfo);
}
const nodeTargetPath: string = targetNode.nodePath;
const remainingPath: string = targetPath.slice(targetPathIndex);
targetPath = path.join(nodeTargetPath, remainingPath);
targetPathIndex = nodeTargetPath.length;
currentNode = targetNode;
}
}
if (targetPath.length === targetPathIndex + 1) {
// We've reached the end of the path
break;
}
}
if (!currentNode) {
throw new Error('Unable to analyze path: ' + inputPath);
}
return currentNode;
}
/**
* Returns a summary of all the symbolic links encountered by {@link SymlinkAnalyzer.analyzePathAsync}.
*/
public reportSymlinks(): ILinkInfo[] {
const list: ILinkInfo[] = [...this._linkInfosByPath.values()];
Sort.sortBy(list, (x) => x.linkPath);
return list;
}
}