55namespace TypePHP \Internal ;
66
77/**
8- * @internal Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
8+ * Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
9+ *
10+ * @internal
911 */
1012final class PathMatcher
1113{
@@ -41,7 +43,7 @@ final class PathMatcher
4143 private static ?string $ cachedLibSrcDir = null ;
4244
4345 /**
44- * Resets compiled pattern and directory caches.
46+ * Resets compiled pattern, prefix, and directory caches.
4547 */
4648 public static function reset (): void
4749 {
@@ -64,16 +66,60 @@ public static function normalizePath(string|false|null $path): string
6466 return str_replace ('\\' , '/ ' , $ path );
6567 }
6668
69+ /**
70+ * Collapses relative directory traversals (..) into canonical paths.
71+ * Preserves leading root slashes and root boundaries.
72+ */
73+ public static function canonicalizePath (string $ path ): string
74+ {
75+ $ path = str_replace ('\\' , '/ ' , $ path );
76+ if (! str_contains ($ path , '.. ' ) && ! str_contains ($ path , '/. ' )) {
77+ return $ path ;
78+ }
79+
80+ $ parts = explode ('/ ' , $ path );
81+ $ absolutes = [];
82+
83+ foreach ($ parts as $ part ) {
84+ if ($ part === '. ' || ($ part === '' && \count ($ absolutes ) > 0 )) {
85+ continue ;
86+ }
87+
88+ if ($ part === '' ) {
89+ $ absolutes [] = '' ;
90+
91+ continue ;
92+ }
93+
94+ if ($ part === '.. ' ) {
95+ if (\count ($ absolutes ) > 0 && end ($ absolutes ) !== '.. ' && end ($ absolutes ) !== '' ) {
96+ array_pop ($ absolutes );
97+ } elseif (\count ($ absolutes ) === 0 || end ($ absolutes ) === '.. ' ) {
98+ $ absolutes [] = '.. ' ;
99+ }
100+ } else {
101+ $ absolutes [] = $ part ;
102+ }
103+ }
104+
105+ if ($ absolutes === ['' ]) {
106+ return '/ ' ;
107+ }
108+
109+ return implode ('/ ' , $ absolutes );
110+ }
111+
67112 /**
68113 * Determines whether a given path is located within a vendor directory.
69114 */
70115 public static function isVendorPath (string $ normalizedPath , string $ rawPath = '' ): bool
71116 {
72- $ normalizedRaw = self ::normalizePath ($ rawPath );
117+ $ canon = self ::canonicalizePath ($ normalizedPath );
118+ $ canonRaw = $ rawPath !== '' ? self ::canonicalizePath (self ::normalizePath ($ rawPath )) : '' ;
73119
74- return str_starts_with ($ normalizedPath , 'vendor/ ' )
75- || str_contains ($ normalizedPath , '/vendor/ ' )
76- || ($ normalizedRaw !== '' && (str_starts_with ($ normalizedRaw , 'vendor/ ' ) || str_contains ($ normalizedRaw , '/vendor/ ' )));
120+ return str_starts_with ($ canon , 'vendor/ ' )
121+ || str_contains ($ canon , '/vendor/ ' )
122+ || ($ canonRaw !== '' && (str_starts_with ($ canonRaw , 'vendor/ ' ) || str_contains ($ canonRaw , '/vendor/ ' )));
77123 }
78124
79125 /**
@@ -85,11 +131,11 @@ public static function isCachePath(string $normalizedPath): bool
85131 self ::$ cachedCacheDir = rtrim (self ::normalizePath (CacheManager::getCacheDir ()), '/ ' ) . '/ ' ;
86132 }
87133
88- return str_starts_with ($ normalizedPath , self ::$ cachedCacheDir );
134+ return str_starts_with (self :: canonicalizePath ( $ normalizedPath) , self ::$ cachedCacheDir );
89135 }
90136
91137 /**
92- * Determines whether a path belongs to TypePHP's own internal engine source files.
138+ * Determines whether a path belongs to TypePHP's internal engine source files.
93139 */
94140 public static function isLibraryInternal (string $ normalizedPath ): bool
95141 {
@@ -103,11 +149,13 @@ public static function isLibraryInternal(string $normalizedPath): bool
103149 return false ;
104150 }
105151
152+ $ canon = self ::canonicalizePath ($ normalizedPath );
153+
106154 if (str_contains ($ libSrcDir , '/vendor/ ' )) {
107- return str_starts_with ($ normalizedPath , $ libSrcDir );
155+ return str_starts_with ($ canon , $ libSrcDir );
108156 }
109157
110- if (str_starts_with ($ normalizedPath , $ libSrcDir )) {
158+ if (str_starts_with ($ canon , $ libSrcDir )) {
111159 $ internalDirs = [
112160 $ libSrcDir . 'Internal/ ' ,
113161 $ libSrcDir . 'Contract/ ' ,
@@ -122,7 +170,7 @@ public static function isLibraryInternal(string $normalizedPath): bool
122170 ];
123171
124172 foreach ($ internalDirs as $ dir ) {
125- if (str_starts_with ($ normalizedPath , $ dir )) {
173+ if (str_starts_with ($ canon , $ dir )) {
126174 return true ;
127175 }
128176 }
@@ -156,47 +204,50 @@ public static function hasIncludeMatchingPrefix(string $prefix, array $includes)
156204 }
157205
158206 /**
159- * Determines whether a directory path is a dynamic writable cache/ log directory.
207+ * Determines whether a directory path is a dynamic writable cache, log, or storage directory.
160208 */
161209 public static function isDynamicWritablePath (string $ normalizedPath ): bool
162210 {
163- return str_contains ($ normalizedPath , '/var/cache/ ' ) || str_starts_with ($ normalizedPath , 'var/cache/ ' )
164- || str_contains ($ normalizedPath , '/var/log/ ' ) || str_starts_with ($ normalizedPath , 'var/log/ ' )
165- || str_contains ($ normalizedPath , '/storage/ ' ) || str_starts_with ($ normalizedPath , 'storage/ ' )
166- || str_contains ($ normalizedPath , '/cache/ ' ) || str_starts_with ($ normalizedPath , 'cache/ ' );
211+ $ canon = self ::canonicalizePath ($ normalizedPath );
212+
213+ return str_contains ($ canon , '/var/cache/ ' ) || str_starts_with ($ canon , 'var/cache/ ' )
214+ || str_contains ($ canon , '/var/log/ ' ) || str_starts_with ($ canon , 'var/log/ ' )
215+ || str_contains ($ canon , '/storage/ ' ) || str_starts_with ($ canon , 'storage/ ' )
216+ || str_contains ($ canon , '/cache/ ' ) || str_starts_with ($ canon , 'cache/ ' );
167217 }
168218
169219 /**
170- * High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included,
171- * while respecting user whitelists for vendor, var, and storage directories.
220+ * High-speed string pre-filter to reject non-application paths before executing regex matching.
172221 */
173222 public static function mayPathBeIncluded (string $ normalizedPath ): bool
174223 {
175- if (str_contains ($ normalizedPath , '/node_modules/ ' ) || str_starts_with ($ normalizedPath , 'node_modules/ ' )) {
224+ $ canon = self ::canonicalizePath ($ normalizedPath );
225+
226+ if (str_contains ($ canon , '/node_modules/ ' ) || str_starts_with ($ canon , 'node_modules/ ' )) {
176227 return false ;
177228 }
178229
179- if (self ::isCachePath ($ normalizedPath )) {
230+ if (self ::isCachePath ($ canon )) {
180231 return false ;
181232 }
182233
183234 $ config = Config::get ();
184235 /** @var array<int, string> $includes */
185236 $ includes = \is_array ($ config ['include ' ] ?? null ) ? $ config ['include ' ] : ['** ' ];
186237
187- if (str_contains ($ normalizedPath , '/vendor/ ' ) || str_starts_with ($ normalizedPath , 'vendor/ ' )) {
238+ if (str_contains ($ canon , '/vendor/ ' ) || str_starts_with ($ canon , 'vendor/ ' )) {
188239 if (! self ::hasIncludeMatchingPrefix ('vendor/ ' , $ includes )) {
189240 return false ;
190241 }
191242 }
192243
193- if (str_contains ($ normalizedPath , '/var/ ' ) || str_starts_with ($ normalizedPath , 'var/ ' )) {
244+ if (str_contains ($ canon , '/var/ ' ) || str_starts_with ($ canon , 'var/ ' )) {
194245 if (! self ::hasIncludeMatchingPrefix ('var/ ' , $ includes )) {
195246 return false ;
196247 }
197248 }
198249
199- if (str_contains ($ normalizedPath , '/storage/ ' ) || str_starts_with ($ normalizedPath , 'storage/ ' )) {
250+ if (str_contains ($ canon , '/storage/ ' ) || str_starts_with ($ canon , 'storage/ ' )) {
200251 if (! self ::hasIncludeMatchingPrefix ('storage/ ' , $ includes )) {
201252 return false ;
202253 }
@@ -213,14 +264,19 @@ public static function compileGlobToRegex(string $glob, string $baseDir): string
213264 $ glob = self ::normalizePath (trim ($ glob ));
214265 $ isAbsolute = str_starts_with ($ glob , '/ ' ) || (bool ) preg_match ('#^[a-zA-Z]:/# ' , $ glob );
215266
216- $ regex = preg_quote ($ glob , '# ' );
217- $ regex = str_replace (['\*\* ' , '\* ' ], ['.* ' , '[^/]* ' ], $ regex );
218-
219267 if ($ isAbsolute ) {
268+ $ regex = preg_quote ($ glob , '# ' );
269+ $ regex = str_replace (['\*\* ' , '\* ' ], ['.* ' , '[^/]* ' ], $ regex );
220270 $ pattern = '^ ' . $ regex . '$ ' ;
221- } elseif ($ glob === '* ' || $ glob === '** ' || str_starts_with ($ glob , '** ' )) {
222- $ pattern = '.* ' . ($ glob === '* ' || $ glob === '** ' ? '' : substr ($ regex , 4 )) . '$ ' ;
271+ } elseif ($ glob === '* ' || $ glob === '** ' ) {
272+ $ pattern = '.*$ ' ;
273+ } elseif (str_starts_with ($ glob , '**/ ' )) {
274+ $ subRegex = preg_quote (substr ($ glob , 3 ), '# ' );
275+ $ subRegex = str_replace (['\*\* ' , '\* ' ], ['.* ' , '[^/]* ' ], $ subRegex );
276+ $ pattern = '(^|.*\/) ' . $ subRegex . '$ ' ;
223277 } else {
278+ $ regex = preg_quote ($ glob , '# ' );
279+ $ regex = str_replace (['\*\* ' , '\* ' ], ['.* ' , '[^/]* ' ], $ regex );
224280 $ pattern = '(^ ' . preg_quote ($ baseDir . '/ ' , '# ' ) . '|^) ' . $ regex . '$ ' ;
225281 }
226282
@@ -241,7 +297,8 @@ public static function isPathIncluded(
241297 ?string $ baseDir = null
242298 ): bool {
243299 $ baseDir = $ baseDir !== null ? self ::normalizePath ($ baseDir ) : Config::getProjectRoot ();
244- $ normalizedRaw = self ::normalizePath ($ rawPath );
300+ $ normalizedPath = self ::canonicalizePath ($ normalizedPath );
301+ $ normalizedRaw = $ rawPath !== '' ? self ::canonicalizePath (self ::normalizePath ($ rawPath )) : '' ;
245302
246303 $ includes = self ::getCompiledPatterns ($ includeGlobs , $ baseDir , 'include ' );
247304 $ excludes = self ::getCompiledPatterns ($ excludeGlobs , $ baseDir , 'exclude ' );
@@ -250,7 +307,8 @@ public static function isPathIncluded(
250307 if ($ isVendor ) {
251308 $ hasExplicitVendorWhitelist = false ;
252309 foreach ($ includes as $ compiled ) {
253- if (str_starts_with ($ compiled ['pattern ' ], 'vendor/ ' ) &&
310+ if (
311+ str_starts_with ($ compiled ['pattern ' ], 'vendor/ ' ) &&
254312 (preg_match ($ compiled ['regex ' ], $ normalizedPath ) === 1 || ($ normalizedRaw !== '' && preg_match ($ compiled ['regex ' ], $ normalizedRaw ) === 1 ))
255313 ) {
256314 $ hasExplicitVendorWhitelist = true ;
@@ -327,4 +385,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin
327385
328386 return self ::$ compiledExcludesCache [$ cacheKey ] = $ compiled ;
329387 }
330- }
388+ }
0 commit comments