Skip to content

Commit a5d11f7

Browse files
authored
refactor(metro-file-map): Pre-resolve symlink targets and store normal posix paths (#1687)
1 parent c165e31 commit a5d11f7

7 files changed

Lines changed: 300 additions & 147 deletions

File tree

‎packages/metro-file-map/src/crawlers/watchman/index.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ export default async function watchmanCrawl({
329329
if (fileData.type === 'l') {
330330
symlinkInfo = fileData['symlink_target'] ?? 1;
331331
}
332+
if (typeof symlinkInfo === 'string') {
333+
symlinkInfo = normalizePathSeparatorsToPosix(
334+
pathUtils.resolveSymlinkToNormal(relativeFilePath, symlinkInfo),
335+
);
336+
}
332337

333338
const nextData: FileMetadata = [
334339
mtime,

‎packages/metro-file-map/src/flow-types.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,9 @@ export type FileMetadata = [
292292
/* size */ number,
293293
/* visited */ 0 | 1,
294294
/* sha1 */ ?string,
295-
/* symlink */ 0 | 1 | string, // string specifies target, if known
295+
// A string is the symlink's target, lexically resolved to a normal path with
296+
// POSIX separators, if known. The target need not exist.
297+
/* symlink */ 0 | 1 | string,
296298
/* plugindata */
297299
...
298300
];

‎packages/metro-file-map/src/index.js‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export type {
167167
// This should be bumped whenever a code change to `metro-file-map` itself
168168
// would cause a change to the cache data structure and/or content (for a given
169169
// filesystem state and build parameters).
170-
const CACHE_BREAKER = '11';
170+
const CACHE_BREAKER = '12';
171171

172172
const CHANGE_INTERVAL = 30;
173173

@@ -217,7 +217,8 @@ const WATCHMAN_REQUIRED_CAPABILITIES = [
217217
* visited: boolean, // whether the file has been parsed or not.
218218
* dependencies: Array<string>, // all relative dependencies of this file.
219219
* sha1: ?string, // SHA-1 of the file, if requested via options.
220-
* symlink: ?(1 | 0 | string), // Truthy if symlink, string is target
220+
* symlink: ?(1 | 0 | string), // Truthy if symlink, string is the target,
221+
* // lexically resolved to a normal POSIX path
221222
* };
222223
*
223224
* // Modules can be targeted to a specific platform based on the file name.
@@ -592,7 +593,9 @@ export default class FileMap extends EventEmitter {
592593
.readlink(this.#pathUtils.normalToAbsolute(normalPath))
593594
.then(symlinkTarget => {
594595
fileMetadata[H.VISITED] = 1;
595-
fileMetadata[H.SYMLINK] = symlinkTarget;
596+
fileMetadata[H.SYMLINK] = normalizePathSeparatorsToPosix(
597+
this.#pathUtils.resolveSymlinkToNormal(normalPath, symlinkTarget),
598+
);
596599
});
597600
}
598601
return null;

‎packages/metro-file-map/src/lib/RootPathUtils.js‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -174,23 +174,25 @@ export class RootPathUtils {
174174
);
175175
}
176176

177-
// If a path is a direct ancestor of the project root (or the root itself),
178-
// return a number with the degrees of separation, e.g. root=0, parent=1,..
179-
// or null otherwise.
180-
getAncestorOfRootIdx(normalPath: string): ?number {
181-
if (normalPath === '') {
182-
return 0;
183-
}
184-
if (normalPath === '..') {
185-
return 1;
186-
}
187-
// Otherwise a *normal* path is only a root ancestor if it is a sequence of
188-
// '../' segments followed by '..', so the length tells us the number of
189-
// up fragments.
190-
if (normalPath.endsWith(SEP_UP_FRAGMENT)) {
191-
return (normalPath.length + 1) / 3;
192-
}
193-
return null;
177+
resolveSymlinkToNormal(
178+
symlinkNormalPath: string,
179+
readlinkResult: string,
180+
): string {
181+
// Lexically resolves the target against the symlink's directory. This is
182+
// string manipulation only: symlinks within the target are not followed,
183+
// and the target need not exist, so the result is not a real path.
184+
//
185+
// readlink returns whatever the link was created with, which need not be
186+
// well-formed (e.g. '..', 'a/./b', 'a//b', or '/' separators on Windows),
187+
// so resolve with node:path. This runs once per symlink, when its node is
188+
// populated, not on traversal.
189+
const normal = this.absoluteToNormal(
190+
path.resolve(this.#rootDir, symlinkNormalPath, '..', readlinkResult),
191+
);
192+
// Normalization keeps a trailing separator when the result is the root or
193+
// an ancestor of it (e.g. a link to '/'), and readlink itself may return
194+
// one. A stored symlink target never has one.
195+
return normal.endsWith(path.sep) ? normal.slice(0, -1) : normal;
194196
}
195197

196198
// Takes a normal and relative path, and joins them efficiently into a normal

‎packages/metro-file-map/src/lib/TreeFS.js‎

Lines changed: 31 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
} from '../flow-types';
2222

2323
import H from '../constants';
24+
import normalizePathSeparatorsToSystem from './normalizePathSeparatorsToSystem';
2425
import {RootPathUtils} from './RootPathUtils';
2526
import invariant from 'invariant';
2627
import path from 'node:path';
@@ -37,12 +38,6 @@ function isRegularFile(node: FileNode): boolean {
3738
return node[H.SYMLINK] === 0;
3839
}
3940

40-
type NormalizedSymlinkTarget = {
41-
ancestorOfRootIdx: ?number,
42-
normalPath: string,
43-
startOfBasenameIdx: number,
44-
};
45-
4641
type DeserializedSnapshotInput = {
4742
rootDir: string,
4843
fileSystemData: DirectoryNode,
@@ -95,10 +90,16 @@ type MetadataIteratorOptions = Readonly<{
9590
*
9691
* SYMLINKS:
9792
*
98-
* Symlinks are represented as nodes whose metadata contains their literal
99-
* target. Literal targets are resolved to normal paths at runtime, and cached.
100-
* If a symlink is encountered during traversal, we restart traversal at the
101-
* root node targeting join(normal symlink target, remaining path suffix).
93+
* Symlinks are represented as nodes whose metadata contains their target,
94+
* lexically resolved to a normal path with POSIX separators when the node was
95+
* populated. Lexical resolution only interprets the literal target relative to
96+
* the symlink's directory: it does not follow symlinks in the target, and the
97+
* target need not exist, so it is a normalPath, not a canonicalPath (see
98+
* TERMINOLOGY). The target is the only path stored inside the tree, so it must
99+
* not use system separators, or the snapshot would not be portable between
100+
* operating systems. If a symlink is encountered during traversal, we restart
101+
* traversal at the root node targeting join(normal symlink target, remaining
102+
* path suffix).
102103
*
103104
* NODE TYPES:
104105
*
@@ -107,8 +108,8 @@ type MetadataIteratorOptions = Readonly<{
107108
* - A file is represented by an `Array` (tuple) of metadata, of which:
108109
* - A regular file has node[H.SYMLINK] === 0
109110
* - A symlink has node[H.SYMLINK] === 1 or
110-
* typeof node[H.SYMLINK] === 'string', where a string is the literal
111-
* content of the symlink (i.e. from readlink), if known.
111+
* typeof node[H.SYMLINK] === 'string', where a string is the target
112+
* lexically resolved to a normal path with POSIX separators, if known.
112113
*
113114
* TERMINOLOGY:
114115
*
@@ -124,10 +125,6 @@ type MetadataIteratorOptions = Readonly<{
124125
* a trailing slash
125126
*/
126127
export default class TreeFS implements MutableFileSystem {
127-
readonly #cachedNormalSymlinkTargets: WeakMap<
128-
FileNode,
129-
NormalizedSymlinkTarget,
130-
> = new WeakMap();
131128
readonly #pathUtils: RootPathUtils;
132129
readonly #processFile: ProcessFileFunction;
133130
readonly #rootDir: Path;
@@ -723,11 +720,16 @@ export default class TreeFS implements MutableFileSystem {
723720
};
724721
}
725722

726-
// Symlink in a directory path
727-
const normalSymlinkTarget = this.#resolveSymlinkTargetToNormalPath(
728-
segmentNode,
729-
currentPath,
723+
// Symlink in a directory path. Targets are stored already lexically
724+
// resolved to a normal path, with POSIX separators so that the
725+
// snapshot is portable between operating systems.
726+
const storedSymlinkTarget = segmentNode[H.SYMLINK];
727+
invariant(
728+
typeof storedSymlinkTarget === 'string',
729+
'Expected symlink target to be populated.',
730730
);
731+
const normalSymlinkTarget =
732+
normalizePathSeparatorsToSystem(storedSymlinkTarget);
731733
if (opts.collectLinkPaths) {
732734
opts.collectLinkPaths.add(
733735
this.#pathUtils.normalToAbsolute(currentPath),
@@ -741,7 +743,7 @@ export default class TreeFS implements MutableFileSystem {
741743
// Append any subsequent path segments to the symlink target, and reset
742744
// with our new target.
743745
const joinedResult = this.#pathUtils.joinNormalToRelative(
744-
normalSymlinkTarget.normalPath,
746+
normalSymlinkTarget,
745747
remainingTargetPath,
746748
);
747749

@@ -761,8 +763,7 @@ export default class TreeFS implements MutableFileSystem {
761763
collectAncestors &&
762764
!isLastSegment &&
763765
// No-op optimisation to bail out the common case of nothing to do.
764-
(normalSymlinkTarget.ancestorOfRootIdx === 0 ||
765-
joinedResult.collapsedSegments > 0)
766+
(normalSymlinkTarget === '' || joinedResult.collapsedSegments > 0)
766767
) {
767768
let node: MixedNode = this.#rootNode;
768769
let collapsedPath = '';
@@ -773,20 +774,12 @@ export default class TreeFS implements MutableFileSystem {
773774
/* for Flow, always true: */ isDirectory(node);
774775
i++
775776
) {
776-
if (
777-
// Add the root only if the target is the root or we have
778-
// collapsed segments.
779-
i > 0 ||
780-
normalSymlinkTarget.ancestorOfRootIdx === 0 ||
781-
joinedResult.collapsedSegments > 0
782-
) {
783-
reverseAncestors.push({
784-
ancestorOfRootIdx: i,
785-
node,
786-
normalPath: collapsedPath,
787-
segmentName: this.#pathUtils.getBasenameOfNthAncestor(i),
788-
});
789-
}
777+
reverseAncestors.push({
778+
ancestorOfRootIdx: i,
779+
node,
780+
normalPath: collapsedPath,
781+
segmentName: this.#pathUtils.getBasenameOfNthAncestor(i),
782+
});
790783
node = node.get('..') ?? new Map();
791784
collapsedPath =
792785
collapsedPath === '' ? '..' : collapsedPath + path.sep + '..';
@@ -800,7 +793,7 @@ export default class TreeFS implements MutableFileSystem {
800793
// the symlink target, and start collecting ancestors only
801794
// from the target itself (ie, the basename of the normal target path)
802795
// onwards.
803-
unseenPathFromIdx = normalSymlinkTarget.startOfBasenameIdx;
796+
unseenPathFromIdx = normalSymlinkTarget.lastIndexOf(path.sep) + 1;
804797

805798
if (seen == null) {
806799
// Optimisation: set this lazily only when we've encountered a symlink
@@ -1222,43 +1215,6 @@ export default class TreeFS implements MutableFileSystem {
12221215
}
12231216
}
12241217

1225-
#resolveSymlinkTargetToNormalPath(
1226-
symlinkNode: FileMetadata,
1227-
canonicalPathOfSymlink: Path,
1228-
): NormalizedSymlinkTarget {
1229-
const cachedResult = this.#cachedNormalSymlinkTargets.get(symlinkNode);
1230-
if (cachedResult != null) {
1231-
return cachedResult;
1232-
}
1233-
1234-
const literalSymlinkTarget = symlinkNode[H.SYMLINK];
1235-
invariant(
1236-
typeof literalSymlinkTarget === 'string',
1237-
'Expected symlink target to be populated.',
1238-
);
1239-
let absoluteSymlinkTarget = path.resolve(
1240-
this.#rootDir,
1241-
canonicalPathOfSymlink,
1242-
'..', // Symlink target is relative to its containing directory.
1243-
literalSymlinkTarget, // May be absolute, in which case the above are ignored
1244-
);
1245-
if (absoluteSymlinkTarget.endsWith(path.sep)) {
1246-
absoluteSymlinkTarget = absoluteSymlinkTarget.slice(0, -1);
1247-
}
1248-
const normalSymlinkTarget = this.#pathUtils.absoluteToNormal(
1249-
absoluteSymlinkTarget,
1250-
);
1251-
1252-
const result = {
1253-
ancestorOfRootIdx:
1254-
this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget),
1255-
normalPath: normalSymlinkTarget,
1256-
startOfBasenameIdx: normalSymlinkTarget.lastIndexOf(path.sep) + 1,
1257-
};
1258-
this.#cachedNormalSymlinkTargets.set(symlinkNode, result);
1259-
return result;
1260-
}
1261-
12621218
#getFileData(
12631219
filePath: Path,
12641220
opts: {followLeaf: boolean} = {followLeaf: true},

‎packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js‎

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -164,18 +164,6 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => {
164164
);
165165
});
166166

167-
test.each([
168-
['foo', null],
169-
['', 0],
170-
['..', 1],
171-
[p('../..'), 2],
172-
[p('../../..'), 3],
173-
[p('../../../foo'), null],
174-
[p('../../../..foo'), null],
175-
])('getAncestorOfRootIdx (%s => %s)', (input, expected) => {
176-
expect(pathUtils.getAncestorOfRootIdx(input)).toEqual(expected);
177-
});
178-
179167
if (platform === 'win32') {
180168
describe('cross-drive absolute paths (Windows)', () => {
181169
test.each([['C:\\project\\root'], ['C:\\']])(
@@ -246,4 +234,99 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => {
246234
);
247235
});
248236
}
237+
238+
describe('resolveSymlinkToNormal', () => {
239+
beforeEach(() => {
240+
pathUtils = new RootPathUtils(p('/project/root'));
241+
});
242+
243+
test.each([
244+
['foo/link', './target.js', p('foo/target.js')],
245+
['foo/link', '../bar.js', 'bar.js'],
246+
['link', 'target.js', 'target.js'],
247+
[p('a/b/link'), p('../../c.js'), 'c.js'],
248+
[p('a/b/link'), p('../../../outside/f.js'), p('../outside/f.js')],
249+
])(
250+
'resolves relative target (%s -> %s) to %s',
251+
(symlinkPath, readlinkResult, expected) => {
252+
expect(
253+
pathUtils.resolveSymlinkToNormal(p(symlinkPath), readlinkResult),
254+
).toEqual(expected);
255+
},
256+
);
257+
258+
// readlink returns the target as the link was created, which need not be
259+
// well-formed. On Windows, these also use '/' separators.
260+
test.each([
261+
['a/link', '..', ''],
262+
['a/link', '.', 'a'],
263+
['a/link', './', 'a'],
264+
['a/link', 'b/..', 'a'],
265+
['a/link', 'b/../c', p('a/c')],
266+
['a/link', 'b/./c', p('a/b/c')],
267+
['a/link', 'b//c', p('a/b/c')],
268+
['a/b/link', '../..', ''],
269+
['a/b/link', '../../..', '..'],
270+
])(
271+
'resolves non-well-formed target (%s -> %s) to %s',
272+
(symlinkPath, readlinkResult, expected) => {
273+
expect(
274+
pathUtils.resolveSymlinkToNormal(p(symlinkPath), readlinkResult),
275+
).toEqual(expected);
276+
},
277+
);
278+
279+
test.each([
280+
['link', p('/project/root/target.js'), 'target.js'],
281+
['link', p('/project/root/a/b.js'), p('a/b.js')],
282+
['link', p('/outside/foo.js'), p('../../outside/foo.js')],
283+
[p('a/link'), p('/project/root'), ''],
284+
])(
285+
'resolves absolute target (%s -> %s) to %s',
286+
(symlinkPath, readlinkResult, expected) => {
287+
expect(
288+
pathUtils.resolveSymlinkToNormal(p(symlinkPath), readlinkResult),
289+
).toEqual(expected);
290+
},
291+
);
292+
293+
test('strips trailing separator from target', () => {
294+
expect(
295+
pathUtils.resolveSymlinkToNormal('link', p('/project/root/dir/')),
296+
).toEqual('dir');
297+
});
298+
299+
// A filesystem root is the one absolute target that consists only of a
300+
// separator, so it must not be trimmed before we test for absoluteness.
301+
// Relative targets may also reach the filesystem root, or pass it: '..' at
302+
// the filesystem root is the filesystem root itself.
303+
test.each([
304+
['link', p('/'), p('../..')],
305+
[p('a/link'), p('/'), p('../..')],
306+
['link', '../..', p('../..')],
307+
['link', '../../../../..', p('../..')],
308+
[p('a/link'), '../../../../../..', p('../..')],
309+
])(
310+
'resolves filesystem root target (%s -> %s) to %s',
311+
(symlinkPath, readlinkResult, expected) => {
312+
expect(
313+
pathUtils.resolveSymlinkToNormal(symlinkPath, readlinkResult),
314+
).toEqual(expected);
315+
},
316+
);
317+
318+
if (platform === 'win32') {
319+
test.each([
320+
['D:\\', '..\\..\\..\\D:'],
321+
['D:\\ext\\', '..\\..\\..\\D:\\ext'],
322+
])(
323+
'resolves cross-drive root target (%s) to %s',
324+
(readlinkResult, expected) => {
325+
expect(
326+
pathUtils.resolveSymlinkToNormal('link', readlinkResult),
327+
).toEqual(expected);
328+
},
329+
);
330+
}
331+
});
249332
});

0 commit comments

Comments
 (0)