Skip to content

Commit 7d452cb

Browse files
committed
Refactor PathMatcher to improve canonicalizePath logic; add comprehensive tests for path resolution scenarios
1 parent 18d77b1 commit 7d452cb

2 files changed

Lines changed: 55 additions & 8 deletions

File tree

src/Internal/PathMatcher.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,14 @@ public static function canonicalizePath(string $path): string
7979

8080
foreach ($parts as $part) {
8181
if ($part === '.' || $part === '') {
82-
if ($part === '' && empty($absolutes)) {
82+
if ($part === '' && $absolutes === []) {
8383
$absolutes[] = '';
8484
}
8585
continue;
8686
}
8787

8888
if ($part === '..') {
89-
if (! empty($absolutes) && end($absolutes) !== '..') {
89+
if ($absolutes !== [] && end($absolutes) !== '..') {
9090
array_pop($absolutes);
9191
} else {
9292
$absolutes[] = '..';
@@ -230,14 +230,12 @@ public static function mayPathBeIncluded(string $normalizedPath): bool
230230
}
231231
}
232232

233-
// 4. var/ check: only inspect if user explicitly whitelisted var
234233
if (str_contains($canon, '/var/') || str_starts_with($canon, 'var/')) {
235234
if (! self::hasIncludeMatchingPrefix('var/', $includes)) {
236235
return false;
237236
}
238237
}
239238

240-
// 5. storage/ check: only inspect if user explicitly whitelisted storage
241239
if (str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')) {
242240
if (! self::hasIncludeMatchingPrefix('storage/', $includes)) {
243241
return false;
@@ -293,8 +291,7 @@ public static function isPathIncluded(
293291
if ($isVendor) {
294292
$hasExplicitVendorWhitelist = false;
295293
foreach ($includes as $compiled) {
296-
if (
297-
str_starts_with($compiled['pattern'], 'vendor/') &&
294+
if (str_starts_with($compiled['pattern'], 'vendor/') &&
298295
(preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1))
299296
) {
300297
$hasExplicitVendorWhitelist = true;
@@ -371,4 +368,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin
371368

372369
return self::$compiledExcludesCache[$cacheKey] = $compiled;
373370
}
374-
}
371+
}

tests/Internal/PathMatcherTest.php

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,40 @@
3838
});
3939
});
4040

41+
describe('canonicalizePath()', function () {
42+
test('resolves relative dot-dot traversals out of vendor directory', function () {
43+
$rawComposerPath = 'vendor/composer/../../tests/Fixtures/Generics/Producer.php';
44+
expect(PathMatcher::canonicalizePath($rawComposerPath))
45+
->toBe('tests/Fixtures/Generics/Producer.php')
46+
;
47+
});
48+
49+
test('resolves Windows backslash dot-dot traversals', function () {
50+
$windowsPath = 'C:\\project\\vendor\\composer\\..\\..\\src\\Service.php';
51+
expect(PathMatcher::canonicalizePath($windowsPath))
52+
->toBe('C:/project/src/Service.php')
53+
;
54+
});
55+
56+
test('resolves absolute Unix paths with dot-dot segments', function () {
57+
$absolutePath = '/var/www/html/app/../src/Core/Util.php';
58+
expect(PathMatcher::canonicalizePath($absolutePath))
59+
->toBe('/var/www/html/src/Core/Util.php')
60+
;
61+
});
62+
63+
test('returns normalized path untouched when no dot-dot segments exist', function () {
64+
$cleanPath = 'src/Controller/WebController.php';
65+
expect(PathMatcher::canonicalizePath($cleanPath))->toBe($cleanPath);
66+
});
67+
68+
test('handles deep multi-level dot-dot resolution cleanly', function () {
69+
expect(PathMatcher::canonicalizePath('a/b/c/d/../../e'))->toBe('a/b/e')
70+
->and(PathMatcher::canonicalizePath('a/b/../../c'))->toBe('c')
71+
;
72+
});
73+
});
74+
4175
describe('isVendorPath()', function () {
4276
test('identifies absolute and relative vendor paths correctly', function () {
4377
expect(PathMatcher::isVendorPath('vendor/doctrine/dbal/src/Schema.php'))->toBeTrue()
@@ -52,6 +86,11 @@
5286
;
5387
});
5488

89+
test('does not classify relative paths traversing OUT of vendor as vendor paths', function () {
90+
$composerRelativeTestPath = 'vendor/composer/../../tests/Fixtures/Generics/Producer.php';
91+
expect(PathMatcher::isVendorPath($composerRelativeTestPath))->toBeFalse();
92+
});
93+
5594
test('does not falsely classify application directories with vendor prefix as vendor directory', function () {
5695
expect(PathMatcher::isVendorPath('vendor-tools/Deploy.php'))->toBeFalse()
5796
->and(PathMatcher::isVendorPath('vendor_custom/Helper.php'))->toBeFalse()
@@ -165,6 +204,17 @@
165204
->and(PathMatcher::mayPathBeIncluded('src/App/Controller.php'))->toBeTrue()
166205
;
167206
});
207+
208+
test('correctly handles relative dot-dot paths traversing out of vendor into included directories', function () {
209+
Config::set([
210+
'include' => ['src/**', 'tests/**'],
211+
'exclude' => ['vendor/**'],
212+
]);
213+
214+
$traversalPath = 'vendor/composer/../../tests/Fixtures/Generics/Producer.php';
215+
216+
expect(PathMatcher::mayPathBeIncluded($traversalPath))->toBeTrue();
217+
});
168218
});
169219

170220
describe('compileGlobToRegex()', function () {
@@ -274,4 +324,4 @@
274324
expect(true)->toBeTrue();
275325
});
276326
});
277-
});
327+
});

0 commit comments

Comments
 (0)