Skip to content

Commit 6e6b52c

Browse files
kittenmeta-codesync[bot]
authored andcommitted
fix(metro-file-map): Fix Windows cross-drive path handling (#1711)
Summary: Related PR for `expo/expo` fork: expo/expo#45648 Supersedes #1696 Cross-device path normals are intended to be relative paths from the rootDir. If the rootDir is at `C:\project` then a cross-device normal path at `D:\temp` is represented as `..\..\D:\temp`. This assumption is broken in several places, like `normalToAbsolute` printing, leading to `C:\D:\` paths that are invalid. Changelog: [Fix] Fix Windows path handling for cross-drive paths Pull Request resolved: #1711 Test Plan: - Unit tests were adjusted to demonstrate all failing cases - **Note:** Invalid unit tests have been removed for paths that rise above the filesystem root (they're assumed to be impossible, and their output doesn't have to be deterministic/correct) Reviewed By: robhogan Differential Revision: D104700077 Pulled By: CalixTang fbshipit-source-id: e77baa4e66495b50ef41b260cec60bcc832d4a6a
1 parent 6d63660 commit 6e6b52c

4 files changed

Lines changed: 238 additions & 39 deletions

File tree

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ const SEP_UP_FRAGMENT = path.sep + '..';
4646
const UP_FRAGMENT_SEP_LENGTH = UP_FRAGMENT_SEP.length;
4747
const CURRENT_FRAGMENT = '.' + path.sep;
4848

49+
const IS_WIN32 = path.sep === '\\';
50+
const ROOT_BASE_IDX = IS_WIN32 ? 0 : 1;
51+
4952
export class RootPathUtils {
5053
#rootDir: string;
5154
#rootDirnames: ReadonlyArray<string>;
@@ -149,6 +152,12 @@ export class RootPathUtils {
149152
const right = pos === 0 ? normalPath : normalPath.slice(pos);
150153
if (right.length === 0) {
151154
return left;
155+
} else if (IS_WIN32 && pos > this.#rootDepth * UP_FRAGMENT_SEP_LENGTH) {
156+
// On a real file system, navigating to `..` at the top level (posix `/`
157+
// or Windows drive) is a no-op, but we can't respect that on Windows
158+
// because Metro uses e.g. `..\..\D:\foo` to represent cross-drive
159+
// relative paths.
160+
return right;
152161
}
153162
// left may already end in a path separator only if it is a filesystem root,
154163
// '/' or 'X:\'.
@@ -198,7 +207,9 @@ export class RootPathUtils {
198207
if (relativePath === '') {
199208
return {collapsedSegments: 0, normalPath};
200209
}
201-
const left = normalPath + path.sep;
210+
const left = normalPath.endsWith(path.sep)
211+
? normalPath
212+
: normalPath + path.sep;
202213
const rawPath = left + relativePath;
203214
if (normalPath === '..' || normalPath.endsWith(SEP_UP_FRAGMENT)) {
204215
const collapsed = this.#tryCollapseIndirectionsInSuffix(rawPath, 0, 0);
@@ -299,9 +310,10 @@ export class RootPathUtils {
299310
};
300311
}
301312

302-
// Cap the number of indirections at the total number of root segments.
303-
// File systems treat '..' at the root as '.'.
304-
if (totalUpIndirections < this.#rootParts.length - 1) {
313+
// Cap the number of indirections at the total number of root parts.
314+
// File systems treat '..' at the root as '.'. For Windows, cross-device
315+
// paths need to survive this.
316+
if (totalUpIndirections < this.#rootParts.length - ROOT_BASE_IDX) {
305317
totalUpIndirections++;
306318
}
307319

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,13 @@ export default class TreeFS implements MutableFileSystem {
493493

494494
remove(mixedPath: Path, changeListener?: FileSystemListener): void {
495495
const normalPath = this.#normalizePath(mixedPath);
496+
this.#removeNormalPath(normalPath, changeListener);
497+
}
498+
499+
#removeNormalPath(
500+
normalPath: string,
501+
changeListener?: FileSystemListener,
502+
): void {
496503
const result = this.#lookupByNormalPath(normalPath, {followLeaf: false});
497504
if (!result.exists) {
498505
return;
@@ -501,7 +508,10 @@ export default class TreeFS implements MutableFileSystem {
501508

502509
if (isDirectory(node) && node.size > 0) {
503510
for (const basename of node.keys()) {
504-
this.remove(canonicalPath + path.sep + basename, changeListener);
511+
this.#removeNormalPath(
512+
canonicalPath + path.sep + basename,
513+
changeListener,
514+
);
505515
}
506516
// Removing the last file will delete this directory
507517
return;
@@ -521,7 +531,7 @@ export default class TreeFS implements MutableFileSystem {
521531
// that's not expected to be a case common enough to justify
522532
// implementation complexity, or slowing down more common uses of
523533
// _lookupByNormalPath.
524-
this.remove(path.dirname(canonicalPath), changeListener);
534+
this.#removeNormalPath(path.dirname(canonicalPath), changeListener);
525535
}
526536
}
527537
}
@@ -1224,16 +1234,19 @@ export default class TreeFS implements MutableFileSystem {
12241234
typeof literalSymlinkTarget === 'string',
12251235
'Expected symlink target to be populated.',
12261236
);
1227-
const absoluteSymlinkTarget = path.resolve(
1237+
let absoluteSymlinkTarget = path.resolve(
12281238
this.#rootDir,
12291239
canonicalPathOfSymlink,
12301240
'..', // Symlink target is relative to its containing directory.
12311241
literalSymlinkTarget, // May be absolute, in which case the above are ignored
12321242
);
1233-
const normalSymlinkTarget = path.relative(
1234-
this.#rootDir,
1243+
if (absoluteSymlinkTarget.endsWith(path.sep)) {
1244+
absoluteSymlinkTarget = absoluteSymlinkTarget.slice(0, -1);
1245+
}
1246+
const normalSymlinkTarget = this.#pathUtils.absoluteToNormal(
12351247
absoluteSymlinkTarget,
12361248
);
1249+
12371250
const result = {
12381251
ancestorOfRootIdx:
12391252
this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget),

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

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -91,37 +91,59 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => {
9191
expect(pathRelative).toHaveBeenCalled();
9292
});
9393

94-
test.each([
95-
p('..'),
96-
p('../..'),
97-
p('../../'),
98-
p('normal/path'),
99-
p('normal/path/'),
100-
p('../normal/path'),
101-
p('../normal/path/'),
102-
p('../../normal/path'),
103-
p('../../../normal/path'),
104-
])(`normalToAbsolute('%s') matches path.resolve`, normalPath => {
105-
let expected = mockPathModule.resolve(rootDir, normalPath);
106-
// Unlike path.resolve, we expect to preserve trailing separators.
107-
if (normalPath.endsWith(sep) && !expected.endsWith(sep)) {
108-
expected += sep;
109-
}
110-
expect(pathUtils.normalToAbsolute(normalPath)).toEqual(expected);
111-
});
94+
const normalToAbsoluteInputs =
95+
rootDir === p('/project/root')
96+
? [
97+
p('..'),
98+
p('../..'),
99+
p('../../'),
100+
p('normal/path'),
101+
p('normal/path/'),
102+
p('../normal/path'),
103+
p('../normal/path/'),
104+
p('../../normal/path'),
105+
// On POSIX, `..` at the root re-enters the root
106+
...(platform === 'posix' ? [p('../../../normal/path')] : []),
107+
]
108+
: [
109+
p('..'),
110+
p('../..'),
111+
p('../../'),
112+
p('normal/path'),
113+
p('normal/path/'),
114+
];
112115

113-
test.each([
114-
p('..'),
115-
p('../root'),
116-
p('../root/path'),
117-
p('../project'),
118-
p('../project/'),
119-
p('../../project/root'),
120-
p('../../project/root/'),
121-
p('../../../normal/path'),
122-
p('../../../normal/path/'),
123-
p('../../..'),
124-
])(
116+
test.each(normalToAbsoluteInputs)(
117+
`normalToAbsolute('%s') matches path.resolve`,
118+
normalPath => {
119+
let expected = mockPathModule.resolve(rootDir, normalPath);
120+
// Unlike path.resolve, we expect to preserve trailing separators.
121+
if (normalPath.endsWith(sep) && !expected.endsWith(sep)) {
122+
expected += sep;
123+
}
124+
expect(pathUtils.normalToAbsolute(normalPath)).toEqual(expected);
125+
},
126+
);
127+
128+
const relativeToNormalInputs =
129+
rootDir === p('/project/root')
130+
? [
131+
p('..'),
132+
p('../root'),
133+
p('../root/path'),
134+
p('../project'),
135+
p('../project/'),
136+
p('../../project/root'),
137+
p('../../project/root/'),
138+
p('../../..'),
139+
// On POSIX, `..` at the root re-enters the root
140+
...(platform === 'posix'
141+
? [p('../../../normal/path'), p('../../../normal/path/')]
142+
: []),
143+
]
144+
: [p('..')];
145+
146+
test.each(relativeToNormalInputs)(
125147
`relativeToNormal('%s') matches path.resolve + path.relative`,
126148
relativePath => {
127149
let expected = mockPathModule.relative(
@@ -153,4 +175,75 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => {
153175
])('getAncestorOfRootIdx (%s => %s)', (input, expected) => {
154176
expect(pathUtils.getAncestorOfRootIdx(input)).toEqual(expected);
155177
});
178+
179+
if (platform === 'win32') {
180+
describe('cross-drive absolute paths (Windows)', () => {
181+
test.each([['C:\\project\\root'], ['C:\\']])(
182+
'path.relative returns cross-drive target as-is from rootDir=%s',
183+
rootDir => {
184+
expect(mockPathModule.relative(rootDir, 'D:\\some\\file.js')).toEqual(
185+
'D:\\some\\file.js',
186+
);
187+
},
188+
);
189+
190+
test.each([
191+
[
192+
'C:\\project\\root',
193+
'D:\\some\\file.js',
194+
'..\\..\\..\\D:\\some\\file.js',
195+
],
196+
['C:\\project\\root', 'D:\\some\\', '..\\..\\..\\D:\\some\\'],
197+
['C:\\project\\root', 'D:\\', '..\\..\\..\\D:\\'],
198+
['C:\\', 'D:\\some\\file.js', '..\\D:\\some\\file.js'],
199+
['C:\\', 'D:\\', '..\\D:\\'],
200+
['D:\\project\\root', 'C:\\file.js', '..\\..\\..\\C:\\file.js'],
201+
])(
202+
'absoluteToNormal emits a ..-chain (rootDir=%s, X=%s -> %s)',
203+
(rootDir, absolutePath, expectedNormal) => {
204+
pathUtils = new RootPathUtils(rootDir);
205+
expect(pathUtils.absoluteToNormal(absolutePath)).toEqual(
206+
expectedNormal,
207+
);
208+
},
209+
);
210+
211+
test.each([
212+
['C:\\project\\root', 'D:\\some\\file.js'],
213+
['C:\\project\\root', 'D:\\some\\'],
214+
['C:\\project\\root', 'D:\\'],
215+
['C:\\', 'D:\\some\\file.js'],
216+
['C:\\', 'D:\\some\\'],
217+
['C:\\', 'D:\\'],
218+
['D:\\project\\root', 'C:\\file.js'],
219+
['D:\\project\\root', 'C:\\'],
220+
])(
221+
'normalToAbsolute(absoluteToNormal(X)) === X for rootDir=%s, X=%s',
222+
(rootDir, absolutePath) => {
223+
pathUtils = new RootPathUtils(rootDir);
224+
const normal = pathUtils.absoluteToNormal(absolutePath);
225+
expect(pathUtils.normalToAbsolute(normal)).toEqual(absolutePath);
226+
},
227+
);
228+
229+
test.each([
230+
['C:\\project\\root', 'D:\\dir\\sub', 'extra\\file.js'],
231+
['C:\\project\\root', 'D:\\', 'foo.js'],
232+
['C:\\', 'D:\\dir', 'sub\\file.js'],
233+
])(
234+
'joinNormalToRelative round-trips cross-drive (rootDir=%s, base=%s, rel=%s)',
235+
(rootDir, baseAbsolute, relativePath) => {
236+
pathUtils = new RootPathUtils(rootDir);
237+
const baseNormal = pathUtils.absoluteToNormal(baseAbsolute);
238+
const {normalPath} = pathUtils.joinNormalToRelative(
239+
baseNormal,
240+
relativePath,
241+
);
242+
expect(pathUtils.normalToAbsolute(normalPath)).toEqual(
243+
mockPathModule.join(baseAbsolute, relativePath),
244+
);
245+
},
246+
);
247+
});
248+
}
156249
});

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,4 +1235,85 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => {
12351235
});
12361236
});
12371237
});
1238+
1239+
if (platform === 'win32') {
1240+
describe('cross-drive paths (Windows)', () => {
1241+
let tfsCD: TreeFSType;
1242+
const externalMeta: FileMetadata = [123, 4, 0, null, 0, 'external'];
1243+
1244+
beforeEach(() => {
1245+
tfsCD = new TreeFS({
1246+
rootDir: 'C:\\project',
1247+
files: new Map<CanonicalPath, FileMetadata>([
1248+
['bar.js', [234, 3, 0, null, 0, 'bar']],
1249+
['..\\..\\D:\\external\\file.js', externalMeta],
1250+
]),
1251+
processFile: () => {
1252+
throw new Error('Not implemented');
1253+
},
1254+
});
1255+
});
1256+
1257+
test('exists() finds a seeded cross-drive file', () => {
1258+
expect(tfsCD.exists('D:\\external\\file.js')).toBe(true);
1259+
});
1260+
1261+
test('lookup() returns the absolute drive-prefixed path as realPath', () => {
1262+
expect(tfsCD.lookup('D:\\external\\file.js')).toMatchObject({
1263+
exists: true,
1264+
type: 'f',
1265+
realPath: 'D:\\external\\file.js',
1266+
});
1267+
});
1268+
1269+
test('getAllFiles() enumerates cross-drive and in-tree files side by side', () => {
1270+
expect(tfsCD.getAllFiles().sort()).toEqual([
1271+
'C:\\project\\bar.js',
1272+
'D:\\external\\file.js',
1273+
]);
1274+
});
1275+
1276+
test('addOrModify() accepts a new cross-drive absolute path', () => {
1277+
tfsCD.addOrModify('D:\\added\\later.js', [1, 1, 0, null, 0, 'later']);
1278+
expect(tfsCD.exists('D:\\added\\later.js')).toBe(true);
1279+
expect(tfsCD.lookup('D:\\added\\later.js')).toMatchObject({
1280+
exists: true,
1281+
type: 'f',
1282+
realPath: 'D:\\added\\later.js',
1283+
});
1284+
});
1285+
1286+
test('remove() deletes a cross-drive entry and prunes empty ancestor dirs', () => {
1287+
tfsCD.remove('D:\\external\\file.js');
1288+
expect(tfsCD.exists('D:\\external\\file.js')).toBe(false);
1289+
expect(tfsCD.lookup('D:\\external').exists).toBe(false);
1290+
expect(tfsCD.exists('C:\\project\\bar.js')).toBe(true);
1291+
});
1292+
1293+
test('lookup() reports missing for non-existent cross-drive path', () => {
1294+
expect(tfsCD.lookup('D:\\external\\missing.js')).toMatchObject({
1295+
exists: false,
1296+
});
1297+
expect(tfsCD.exists('E:\\anywhere.js')).toBe(false);
1298+
});
1299+
1300+
test('lookup() follows a symlink whose target is a cross-drive path', () => {
1301+
const tfsLink = new TreeFS({
1302+
rootDir: 'C:\\project',
1303+
files: new Map<CanonicalPath, FileMetadata>([
1304+
['..\\..\\D:\\external\\file.js', externalMeta],
1305+
['link', [0, 0, 0, null, 'D:\\external\\file.js', null]],
1306+
]),
1307+
processFile: () => {
1308+
throw new Error('Not implemented');
1309+
},
1310+
});
1311+
expect(tfsLink.lookup('C:\\project\\link')).toMatchObject({
1312+
exists: true,
1313+
type: 'f',
1314+
realPath: 'D:\\external\\file.js',
1315+
});
1316+
});
1317+
});
1318+
}
12381319
});

0 commit comments

Comments
 (0)