@@ -42,10 +42,26 @@ final class StreamWrapper implements StreamWrapperInterface
4242 private static string $ cacheDir = '' ;
4343
4444 /**
45- * Resets the compiled pattern cache. Useful for test isolation.
45+ * In-memory cache for url_stat to avoid 100,000+ unregister/register cycles on file_exists/is_file.
46+ *
47+ * @var array<string, array<int|string, int>|false>
48+ */
49+ private static array $ statCache = [];
50+
51+ /**
52+ * In-memory cache for isApplicationFile path decisions.
53+ *
54+ * @var array<string, bool>
55+ */
56+ private static array $ appFileDecisionCache = [];
57+
58+ /**
59+ * Resets all internal caches.
4660 */
4761 public static function reset (): void
4862 {
63+ self ::$ statCache = [];
64+ self ::$ appFileDecisionCache = [];
4965 PathMatcher::reset ();
5066 }
5167
@@ -128,6 +144,8 @@ public static function transformSource(string $source, string $filePath = ''): s
128144
129145 $ transformedLineCount = substr_count ($ transformed , "\n" );
130146 $ drift = $ transformedLineCount - $ originalLineCount ;
147+ $ count1 = 0 ;
148+ $ count2 = 0 ;
131149
132150 if ($ drift > 0 ) {
133151 $ transformed = preg_replace ('/[ \t]*\r?\n[ \t]*\{[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\// ' , ' { /*__TYPEPHP_INJECTED_START__*/ ' , $ transformed , $ drift , $ count1 ) ?? $ transformed ;
@@ -153,6 +171,17 @@ public static function transformSource(string $source, string $filePath = ''): s
153171 */
154172 public function stream_open (string $ path , string $ mode , int $ options , ?string &$ openedPath ): bool
155173 {
174+ // Fast-path: non-PHP files are never transformed
175+ if (! str_ends_with (strtolower ($ path ), '.php ' )) {
176+ return $ this ->openDirectHandle ($ path , $ mode );
177+ }
178+
179+ // Fast-path: unwhitelisted vendor files are never transformed
180+ $ normalizedRaw = str_replace ('\\' , '/ ' , $ path );
181+ if (str_contains ($ normalizedRaw , '/vendor/ ' ) || str_starts_with ($ normalizedRaw , 'vendor/ ' )) {
182+ return $ this ->openDirectHandle ($ path , $ mode );
183+ }
184+
156185 self ::unregister ();
157186 $ exists = self ::silent (fn () => file_exists ($ path ));
158187 $ resolvedPath = $ exists ? realpath ($ path ) : '' ;
@@ -161,16 +190,7 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
161190 $ isAppFile = $ exists && ! self ::isReadOnlyCall () && self ::isApplicationFile ($ path , $ resolvedPath );
162191
163192 if (! $ isAppFile || $ resolvedPath === false ) {
164- self ::unregister ();
165- $ targetFile = ($ resolvedPath !== false && $ resolvedPath !== '' ) ? $ resolvedPath : $ path ;
166-
167- /** @var resource|false $handle */
168- $ handle = self ::silent (fn () => fopen ($ targetFile , $ mode ));
169-
170- $ this ->handle = $ handle !== false ? $ handle : null ;
171- self ::register ();
172-
173- return $ this ->handle !== null ;
193+ return $ this ->openDirectHandle (($ resolvedPath !== false && $ resolvedPath !== '' ) ? $ resolvedPath : $ path , $ mode );
174194 }
175195
176196 self ::unregister ();
@@ -184,6 +204,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
184204 return $ success ;
185205 }
186206
207+ /**
208+ * Opens a raw file handle directly without stream interception.
209+ */
210+ private function openDirectHandle (string $ targetFile , string $ mode ): bool
211+ {
212+ self ::unregister ();
213+ /** @var resource|false $handle */
214+ $ handle = self ::silent (fn () => fopen ($ targetFile , $ mode ));
215+ $ this ->handle = $ handle !== false ? $ handle : null ;
216+ self ::register ();
217+
218+ return $ this ->handle !== null ;
219+ }
220+
187221 public function stream_read (int $ count ): string
188222 {
189223 if ($ this ->handle === null || $ count <= 0 ) {
@@ -286,7 +320,7 @@ public function stream_seek(int $offset, int $whence = SEEK_SET): bool
286320 return false ;
287321 }
288322
289- return fseek ($ this ->handle , $ offset , $ whence ) === 0 ;
323+ return @ fseek ($ this ->handle , $ offset , $ whence ) === 0 ;
290324 }
291325
292326 public function stream_set_option (int $ option , int $ arg1 , int $ arg2 ): bool
@@ -303,16 +337,23 @@ public function stream_close(): void
303337 }
304338
305339 /**
340+ * High-speed stat resolution with $O(1)$ memoization cache.
341+ *
306342 * @return array<int|string, int>|false
307343 */
308344 public function url_stat (string $ path , int $ flags ): array |false
309345 {
346+ $ normalized = str_replace ('\\' , '/ ' , $ path );
347+ if (\array_key_exists ($ normalized , self ::$ statCache )) {
348+ return self ::$ statCache [$ normalized ];
349+ }
350+
310351 self ::unregister ();
311352 /** @var array<int|string, int>|false $result */
312353 $ result = self ::silent (fn () => stat ($ path ));
313354 self ::register ();
314355
315- return $ result ;
356+ return self :: $ statCache [ $ normalized ] = $ result ;
316357 }
317358
318359 public function stream_metadata (string $ path , int $ option , mixed $ value ): bool
@@ -396,6 +437,9 @@ public function rmdir(string $path, int $options): bool
396437
397438 public function unlink (string $ path ): bool
398439 {
440+ $ normalized = str_replace ('\\' , '/ ' , $ path );
441+ unset(self ::$ statCache [$ normalized ]);
442+
399443 self ::unregister ();
400444 $ result = (bool ) self ::silent (fn () => unlink ($ path ));
401445 self ::register ();
@@ -405,13 +449,29 @@ public function unlink(string $path): bool
405449
406450 public function rename (string $ pathFrom , string $ pathTo ): bool
407451 {
452+ $ normFrom = str_replace ('\\' , '/ ' , $ pathFrom );
453+ $ normTo = str_replace ('\\' , '/ ' , $ pathTo );
454+ unset(self ::$ statCache [$ normFrom ], self ::$ statCache [$ normTo ]);
455+
408456 self ::unregister ();
409457 $ result = (bool ) self ::silent (fn () => rename ($ pathFrom , $ pathTo ));
410458 self ::register ();
411459
412460 return $ result ;
413461 }
414462
463+ /**
464+ * Determines if the current stream_open call is directly for reading file contents
465+ * rather than PHP engine's require/include execution.
466+ */
467+ private static function isReadOnlyCall (): bool
468+ {
469+ $ trace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS , 3 );
470+ $ callerFunc = strtolower ($ trace [2 ]['function ' ] ?? '' );
471+
472+ return \in_array ($ callerFunc , ['file_get_contents ' , 'file ' , 'readfile ' , 'highlight_file ' , 'show_source ' , 'token_get_all ' ], true );
473+ }
474+
415475 /**
416476 * Executes a callback while temporarily suppressing PHP error and warning handlers.
417477 *
@@ -433,19 +493,7 @@ private static function silent(callable $callback): mixed
433493 }
434494
435495 /**
436- * Determines if the current stream_open call is directly for reading file contents (e.g. file_get_contents)
437- * rather than PHP engine's require/include execution.
438- */
439- private static function isReadOnlyCall (): bool
440- {
441- $ trace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS , 3 );
442- $ callerFunc = strtolower ($ trace [2 ]['function ' ] ?? '' );
443-
444- return \in_array ($ callerFunc , ['file_get_contents ' , 'file ' , 'readfile ' , 'highlight_file ' , 'show_source ' , 'token_get_all ' ], strict: true );
445- }
446-
447- /**
448- * Determines whether a target PHP file path should be intercepted using Pattern Specificity.
496+ * Determines whether a target PHP file path should be intercepted with $O(1)$ caching.
449497 */
450498 private static function isApplicationFile (string $ path , string |false $ resolvedPath ): bool
451499 {
@@ -459,12 +507,16 @@ private static function isApplicationFile(string $path, string|false $resolvedPa
459507
460508 $ normalizedPath = PathMatcher::normalizePath ($ resolvedPath );
461509
510+ if (isset (self ::$ appFileDecisionCache [$ normalizedPath ])) {
511+ return self ::$ appFileDecisionCache [$ normalizedPath ];
512+ }
513+
462514 if (PathMatcher::isLibraryInternal ($ normalizedPath )) {
463- return false ;
515+ return self :: $ appFileDecisionCache [ $ normalizedPath ] = false ;
464516 }
465517
466518 if (PathMatcher::isCachePath ($ normalizedPath )) {
467- return false ;
519+ return self :: $ appFileDecisionCache [ $ normalizedPath ] = false ;
468520 }
469521
470522 $ config = Config::get ();
@@ -473,7 +525,9 @@ private static function isApplicationFile(string $path, string|false $resolvedPa
473525 /** @var array<int, string> $excludes */
474526 $ excludes = \is_array ($ config ['exclude ' ] ?? null ) ? $ config ['exclude ' ] : ['vendor/** ' , 'storage/** ' , 'var/** ' , 'cache/** ' ];
475527
476- return PathMatcher::isPathIncluded ($ normalizedPath , $ includes , $ excludes , $ path );
528+ $ isIncluded = PathMatcher::isPathIncluded ($ normalizedPath , $ includes , $ excludes , $ path );
529+
530+ return self ::$ appFileDecisionCache [$ normalizedPath ] = $ isIncluded ;
477531 }
478532
479533 private function openMemoryStream (string $ resolvedPath ): bool
@@ -502,7 +556,7 @@ private function openCachedStream(string $resolvedPath, string $mode): bool
502556 {
503557 $ cacheDir = self ::$ cacheDir ;
504558 if (! is_dir ($ cacheDir )) {
505- self ::silent (fn () => mkdir ($ cacheDir , 0777 , recursive: true ));
559+ self ::silent (fn () => mkdir ($ cacheDir , 0777 , true ));
506560 }
507561
508562 $ cachedFile = CacheManager::getCachedFilePath ($ resolvedPath );
0 commit comments