@@ -48,19 +48,42 @@ final class StreamWrapper implements StreamWrapperInterface
4848 */
4949 private static array $ statCache = [];
5050
51+ /**
52+ * In-memory cache for static-path negative misses only (e.g. vendor).
53+ * Never stores negative misses for dynamic paths (var/cache, storage).
54+ *
55+ * @var array<string, true>
56+ */
57+ private static array $ staticNegativeStatCache = [];
58+
5159 /**
5260 * In-memory cache for isApplicationFile path decisions.
5361 *
5462 * @var array<string, bool>
5563 */
5664 private static array $ appFileDecisionCache = [];
5765
66+ /**
67+ * Fast-lookup table for read-only source view functions.
68+ *
69+ * @var array<string, true>
70+ */
71+ private const READ_ONLY_FUNCTIONS = [
72+ 'file_get_contents ' => true ,
73+ 'file ' => true ,
74+ 'readfile ' => true ,
75+ 'highlight_file ' => true ,
76+ 'show_source ' => true ,
77+ 'token_get_all ' => true ,
78+ ];
79+
5880 /**
5981 * Resets all internal caches.
6082 */
6183 public static function reset (): void
6284 {
6385 self ::$ statCache = [];
86+ self ::$ staticNegativeStatCache = [];
6487 self ::$ appFileDecisionCache = [];
6588 PathMatcher::reset ();
6689 }
@@ -171,26 +194,47 @@ public static function transformSource(string $source, string $filePath = ''): s
171194 */
172195 public function stream_open (string $ path , string $ mode , int $ options , ?string &$ openedPath ): bool
173196 {
174- // Fast-path: non-PHP files are never transformed
197+ if ($ mode !== 'r ' && $ mode !== 'rb ' && $ mode !== 'rt ' ) {
198+ return $ this ->openDirectHandle ($ path , $ mode );
199+ }
200+
175201 if (! str_ends_with (strtolower ($ path ), '.php ' )) {
176202 return $ this ->openDirectHandle ($ path , $ mode );
177203 }
178204
179- // Fast-path: unwhitelisted vendor files are never transformed
205+ if (! Config::isEnabled ()) {
206+ return $ this ->openDirectHandle ($ path , $ mode );
207+ }
208+
180209 $ normalizedRaw = str_replace ('\\' , '/ ' , $ path );
181- if (str_contains ($ normalizedRaw , '/vendor/ ' ) || str_starts_with ($ normalizedRaw , 'vendor/ ' )) {
210+
211+ if (! PathMatcher::mayPathBeIncluded ($ normalizedRaw )) {
182212 return $ this ->openDirectHandle ($ path , $ mode );
183213 }
184214
215+ if (isset (self ::$ appFileDecisionCache [$ normalizedRaw ])) {
216+ if (! self ::$ appFileDecisionCache [$ normalizedRaw ]) {
217+ return $ this ->openDirectHandle ($ path , $ mode );
218+ }
219+ }
220+
185221 self ::unregister ();
186- $ exists = self ::silent (fn () => file_exists ($ path ));
187- $ resolvedPath = $ exists ? realpath ($ path ) : '' ;
222+ $ exists = ( bool ) self ::silent (fn () => file_exists ($ path ));
223+ $ resolvedPath = $ exists ? self :: silent ( fn () => realpath ($ path )) : false ;
188224 self ::register ();
189225
190- $ isAppFile = $ exists && ! self ::isReadOnlyCall () && self ::isApplicationFile ($ path , $ resolvedPath );
226+ if (! $ exists || $ resolvedPath === false ) {
227+ return $ this ->openDirectHandle ($ path , $ mode );
228+ }
229+
230+ $ normalizedResolved = str_replace ('\\' , '/ ' , $ resolvedPath );
231+
232+ if (! self ::isApplicationFile ($ path , $ resolvedPath )) {
233+ return $ this ->openDirectHandle ($ normalizedResolved , $ mode );
234+ }
191235
192- if (! $ isAppFile || $ resolvedPath === false ) {
193- return $ this ->openDirectHandle (( $ resolvedPath !== false && $ resolvedPath !== '' ) ? $ resolvedPath : $ path , $ mode );
236+ if (self :: isReadOnlyCall () ) {
237+ return $ this ->openDirectHandle ($ normalizedResolved , $ mode );
194238 }
195239
196240 self ::unregister ();
@@ -204,6 +248,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
204248 return $ success ;
205249 }
206250
251+ /**
252+ * Determines if the stream_open call is for reading raw file contents/snippets
253+ * (e.g. error screen renderers) rather than PHP engine execution.
254+ */
255+ private static function isReadOnlyCall (): bool
256+ {
257+ $ trace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS , 3 );
258+
259+ $ caller1 = strtolower ($ trace [1 ]['function ' ] ?? '' );
260+ $ caller2 = strtolower ($ trace [2 ]['function ' ] ?? '' );
261+
262+ return isset (self ::READ_ONLY_FUNCTIONS [$ caller1 ]) || isset (self ::READ_ONLY_FUNCTIONS [$ caller2 ]);
263+ }
264+
207265 /**
208266 * Opens a raw file handle directly without stream interception.
209267 */
@@ -338,25 +396,36 @@ public function stream_close(): void
338396
339397 /**
340398 * High-speed stat resolution with $O(1)$ memoization cache.
341- * Never caches false (negative lookups) so newly created directories and files are immediately discovered.
399+ * Caches positive stat hits.
400+ * Only caches negative misses for STATIC directories (vendor, tests).
401+ * NEVER caches negative misses for dynamic writable paths (var/cache, storage),
402+ * guaranteeing Symfony/Shopware cache creation is detected immediately.
342403 *
343404 * @return array<int|string, int>|false
344405 */
345406 public function url_stat (string $ path , int $ flags ): array |false
346407 {
347408 $ normalized = str_replace ('\\' , '/ ' , $ path );
409+
348410 if (isset (self ::$ statCache [$ normalized ])) {
349411 return self ::$ statCache [$ normalized ];
350412 }
351413
414+ if (isset (self ::$ staticNegativeStatCache [$ normalized ])) {
415+ return false ;
416+ }
417+
352418 self ::unregister ();
353419 /** @var array<int|string, int>|false $result */
354420 $ result = self ::silent (fn () => stat ($ path ));
355421 self ::register ();
356422
357- // Only cache positive results (existing files/dirs)
358423 if ($ result !== false ) {
359424 self ::$ statCache [$ normalized ] = $ result ;
425+ } else {
426+ if (! PathMatcher::isDynamicWritablePath ($ normalized )) {
427+ self ::$ staticNegativeStatCache [$ normalized ] = true ;
428+ }
360429 }
361430
362431 return $ result ;
@@ -365,7 +434,7 @@ public function url_stat(string $path, int $flags): array|false
365434 public function stream_metadata (string $ path , int $ option , mixed $ value ): bool
366435 {
367436 $ normalized = str_replace ('\\' , '/ ' , $ path );
368- unset(self ::$ statCache [$ normalized ]);
437+ unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
369438
370439 self ::unregister ();
371440 $ result = false ;
@@ -429,10 +498,10 @@ public function dir_closedir(): bool
429498 public function mkdir (string $ path , int $ mode , int $ options ): bool
430499 {
431500 $ normalized = str_replace ('\\' , '/ ' , $ path );
432- unset(self ::$ statCache [$ normalized ]);
501+ unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
433502
434503 self ::unregister ();
435- $ result = (bool ) self ::silent (fn () => mkdir ($ path , $ mode , (bool ) ( $ options & STREAM_MKDIR_RECURSIVE )));
504+ $ result = (bool ) self ::silent (fn () => mkdir ($ path , $ mode , ($ options & STREAM_MKDIR_RECURSIVE ) !== 0 ));
436505 self ::register ();
437506
438507 return $ result ;
@@ -441,7 +510,7 @@ public function mkdir(string $path, int $mode, int $options): bool
441510 public function rmdir (string $ path , int $ options ): bool
442511 {
443512 $ normalized = str_replace ('\\' , '/ ' , $ path );
444- unset(self ::$ statCache [$ normalized ]);
513+ unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
445514
446515 self ::unregister ();
447516 $ result = (bool ) self ::silent (fn () => rmdir ($ path ));
@@ -453,7 +522,7 @@ public function rmdir(string $path, int $options): bool
453522 public function unlink (string $ path ): bool
454523 {
455524 $ normalized = str_replace ('\\' , '/ ' , $ path );
456- unset(self ::$ statCache [$ normalized ]);
525+ unset(self ::$ statCache [$ normalized ], self :: $ staticNegativeStatCache [ $ normalized ] );
457526
458527 self ::unregister ();
459528 $ result = (bool ) self ::silent (fn () => unlink ($ path ));
@@ -466,7 +535,12 @@ public function rename(string $pathFrom, string $pathTo): bool
466535 {
467536 $ normFrom = str_replace ('\\' , '/ ' , $ pathFrom );
468537 $ normTo = str_replace ('\\' , '/ ' , $ pathTo );
469- unset(self ::$ statCache [$ normFrom ], self ::$ statCache [$ normTo ]);
538+ unset(
539+ self ::$ statCache [$ normFrom ],
540+ self ::$ statCache [$ normTo ],
541+ self ::$ staticNegativeStatCache [$ normFrom ],
542+ self ::$ staticNegativeStatCache [$ normTo ]
543+ );
470544
471545 self ::unregister ();
472546 $ result = (bool ) self ::silent (fn () => rename ($ pathFrom , $ pathTo ));
@@ -475,18 +549,6 @@ public function rename(string $pathFrom, string $pathTo): bool
475549 return $ result ;
476550 }
477551
478- /**
479- * Determines if the current stream_open call is directly for reading file contents
480- * rather than PHP engine's require/include execution.
481- */
482- private static function isReadOnlyCall (): bool
483- {
484- $ trace = debug_backtrace (DEBUG_BACKTRACE_IGNORE_ARGS , 3 );
485- $ callerFunc = strtolower ($ trace [2 ]['function ' ] ?? '' );
486-
487- return \in_array ($ callerFunc , ['file_get_contents ' , 'file ' , 'readfile ' , 'highlight_file ' , 'show_source ' , 'token_get_all ' ], true );
488- }
489-
490552 /**
491553 * Executes a callback while temporarily suppressing PHP error and warning handlers.
492554 *
0 commit comments