@@ -64,16 +64,52 @@ public static function normalizePath(string|false|null $path): string
6464 return str_replace ('\\' , '/ ' , $ path );
6565 }
6666
67+ /**
68+ * Resolves dot-dot (..) relative segments in a path without hitting the disk.
69+ */
70+ public static function canonicalizePath (string $ path ): string
71+ {
72+ $ path = str_replace ('\\' , '/ ' , $ path );
73+ if (! str_contains ($ path , '.. ' )) {
74+ return $ path ;
75+ }
76+
77+ $ parts = explode ('/ ' , $ path );
78+ $ absolutes = [];
79+
80+ foreach ($ parts as $ part ) {
81+ if ($ part === '. ' || $ part === '' ) {
82+ if ($ part === '' && empty ($ absolutes )) {
83+ $ absolutes [] = '' ;
84+ }
85+ continue ;
86+ }
87+
88+ if ($ part === '.. ' ) {
89+ if (! empty ($ absolutes ) && end ($ absolutes ) !== '.. ' ) {
90+ array_pop ($ absolutes );
91+ } else {
92+ $ absolutes [] = '.. ' ;
93+ }
94+ } else {
95+ $ absolutes [] = $ part ;
96+ }
97+ }
98+
99+ return implode ('/ ' , $ absolutes );
100+ }
101+
67102 /**
68103 * Determines whether a given path is located within a vendor directory.
69104 */
70105 public static function isVendorPath (string $ normalizedPath , string $ rawPath = '' ): bool
71106 {
72- $ normalizedRaw = self ::normalizePath ($ rawPath );
107+ $ canon = self ::canonicalizePath ($ normalizedPath );
108+ $ canonRaw = $ rawPath !== '' ? self ::canonicalizePath ($ rawPath ) : '' ;
73109
74- return str_starts_with ($ normalizedPath , 'vendor/ ' )
75- || str_contains ($ normalizedPath , '/vendor/ ' )
76- || ($ normalizedRaw !== '' && (str_starts_with ($ normalizedRaw , 'vendor/ ' ) || str_contains ($ normalizedRaw , '/vendor/ ' )));
110+ return str_starts_with ($ canon , 'vendor/ ' )
111+ || str_contains ($ canon , '/vendor/ ' )
112+ || ($ canonRaw !== '' && (str_starts_with ($ canonRaw , 'vendor/ ' ) || str_contains ($ canonRaw , '/vendor/ ' )));
77113 }
78114
79115 /**
@@ -160,10 +196,12 @@ public static function hasIncludeMatchingPrefix(string $prefix, array $includes)
160196 */
161197 public static function isDynamicWritablePath (string $ normalizedPath ): bool
162198 {
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/ ' );
199+ $ canon = self ::canonicalizePath ($ normalizedPath );
200+
201+ return str_contains ($ canon , '/var/cache/ ' ) || str_starts_with ($ canon , 'var/cache/ ' )
202+ || str_contains ($ canon , '/var/log/ ' ) || str_starts_with ($ canon , 'var/log/ ' )
203+ || str_contains ($ canon , '/storage/ ' ) || str_starts_with ($ canon , 'storage/ ' )
204+ || str_contains ($ canon , '/cache/ ' ) || str_starts_with ($ canon , 'cache/ ' );
167205 }
168206
169207 /**
@@ -172,31 +210,35 @@ public static function isDynamicWritablePath(string $normalizedPath): bool
172210 */
173211 public static function mayPathBeIncluded (string $ normalizedPath ): bool
174212 {
175- if (str_contains ($ normalizedPath , '/node_modules/ ' ) || str_starts_with ($ normalizedPath , 'node_modules/ ' )) {
213+ $ canon = self ::canonicalizePath ($ normalizedPath );
214+
215+ if (str_contains ($ canon , '/node_modules/ ' ) || str_starts_with ($ canon , 'node_modules/ ' )) {
176216 return false ;
177217 }
178218
179- if (self ::isCachePath ($ normalizedPath )) {
219+ if (self ::isCachePath ($ canon )) {
180220 return false ;
181221 }
182222
183223 $ config = Config::get ();
184224 /** @var array<int, string> $includes */
185225 $ includes = \is_array ($ config ['include ' ] ?? null ) ? $ config ['include ' ] : ['** ' ];
186226
187- if (str_contains ($ normalizedPath , '/vendor/ ' ) || str_starts_with ($ normalizedPath , 'vendor/ ' )) {
227+ if (str_contains ($ canon , '/vendor/ ' ) || str_starts_with ($ canon , 'vendor/ ' )) {
188228 if (! self ::hasIncludeMatchingPrefix ('vendor/ ' , $ includes )) {
189229 return false ;
190230 }
191231 }
192232
193- if (str_contains ($ normalizedPath , '/var/ ' ) || str_starts_with ($ normalizedPath , 'var/ ' )) {
233+ // 4. var/ check: only inspect if user explicitly whitelisted var
234+ if (str_contains ($ canon , '/var/ ' ) || str_starts_with ($ canon , 'var/ ' )) {
194235 if (! self ::hasIncludeMatchingPrefix ('var/ ' , $ includes )) {
195236 return false ;
196237 }
197238 }
198239
199- if (str_contains ($ normalizedPath , '/storage/ ' ) || str_starts_with ($ normalizedPath , 'storage/ ' )) {
240+ // 5. storage/ check: only inspect if user explicitly whitelisted storage
241+ if (str_contains ($ canon , '/storage/ ' ) || str_starts_with ($ canon , 'storage/ ' )) {
200242 if (! self ::hasIncludeMatchingPrefix ('storage/ ' , $ includes )) {
201243 return false ;
202244 }
@@ -241,7 +283,8 @@ public static function isPathIncluded(
241283 ?string $ baseDir = null
242284 ): bool {
243285 $ baseDir = $ baseDir !== null ? self ::normalizePath ($ baseDir ) : Config::getProjectRoot ();
244- $ normalizedRaw = self ::normalizePath ($ rawPath );
286+ $ normalizedPath = self ::canonicalizePath ($ normalizedPath );
287+ $ normalizedRaw = $ rawPath !== '' ? self ::canonicalizePath (self ::normalizePath ($ rawPath )) : '' ;
245288
246289 $ includes = self ::getCompiledPatterns ($ includeGlobs , $ baseDir , 'include ' );
247290 $ excludes = self ::getCompiledPatterns ($ excludeGlobs , $ baseDir , 'exclude ' );
@@ -250,7 +293,8 @@ public static function isPathIncluded(
250293 if ($ isVendor ) {
251294 $ hasExplicitVendorWhitelist = false ;
252295 foreach ($ includes as $ compiled ) {
253- if (str_starts_with ($ compiled ['pattern ' ], 'vendor/ ' ) &&
296+ if (
297+ str_starts_with ($ compiled ['pattern ' ], 'vendor/ ' ) &&
254298 (preg_match ($ compiled ['regex ' ], $ normalizedPath ) === 1 || ($ normalizedRaw !== '' && preg_match ($ compiled ['regex ' ], $ normalizedRaw ) === 1 ))
255299 ) {
256300 $ hasExplicitVendorWhitelist = true ;
0 commit comments