Skip to content

Commit eb93b50

Browse files
committed
Refactor StreamWrapper and PathMatcher classes to remove unused caches and streamline path handling; enhance readability and performance
1 parent 4df0082 commit eb93b50

4 files changed

Lines changed: 50 additions & 302 deletions

File tree

src/Internal/PathMatcher.php

Lines changed: 11 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,6 @@ final class PathMatcher
2323
*/
2424
private static array $compiledExcludesCache = [];
2525

26-
/**
27-
* Cache for include prefix lookup decisions.
28-
*
29-
* @var array<string, bool>
30-
*/
31-
private static array $includePrefixCache = [];
32-
3326
/**
3427
* Cached normalized cache directory path.
3528
*/
@@ -47,7 +40,6 @@ public static function reset(): void
4740
{
4841
self::$compiledIncludesCache = [];
4942
self::$compiledExcludesCache = [];
50-
self::$includePrefixCache = [];
5143
self::$cachedCacheDir = null;
5244
self::$cachedLibSrcDir = null;
5345
}
@@ -64,6 +56,9 @@ public static function normalizePath(string|false|null $path): string
6456
return str_replace('\\', '/', $path);
6557
}
6658

59+
/**
60+
* Collapses relative directory traversals (..) into absolute canonical paths.
61+
*/
6762
public static function canonicalizePath(string $path): string
6863
{
6964
$path = str_replace('\\', '/', $path);
@@ -79,7 +74,6 @@ public static function canonicalizePath(string $path): string
7974
if ($part === '' && \count($absolutes) === 0) {
8075
$absolutes[] = '';
8176
}
82-
8377
continue;
8478
}
8579

@@ -100,14 +94,11 @@ public static function canonicalizePath(string $path): string
10094
/**
10195
* Determines whether a given path is located within a vendor directory.
10296
*/
103-
public static function isVendorPath(string $normalizedPath, string $rawPath = ''): bool
97+
public static function isVendorPath(string $normalizedPath, string $normalizedRaw = ''): bool
10498
{
105-
$canon = self::canonicalizePath($normalizedPath);
106-
$canonRaw = $rawPath !== '' ? self::canonicalizePath($rawPath) : '';
107-
108-
return str_starts_with($canon, 'vendor/')
109-
|| str_contains($canon, '/vendor/')
110-
|| ($canonRaw !== '' && (str_starts_with($canonRaw, 'vendor/') || str_contains($canonRaw, '/vendor/')));
99+
return str_starts_with($normalizedPath, 'vendor/')
100+
|| str_contains($normalizedPath, '/vendor/')
101+
|| ($normalizedRaw !== '' && (str_starts_with($normalizedRaw, 'vendor/') || str_contains($normalizedRaw, '/vendor/')));
111102
}
112103

113104
/**
@@ -124,6 +115,8 @@ public static function isCachePath(string $normalizedPath): bool
124115

125116
/**
126117
* Determines whether a path belongs to TypePHP's own internal engine source files.
118+
* In vendor mode: skips the entire library package.
119+
* In development mode: skips only actual internal subdirectories, allowing test fixtures to be tested.
127120
*/
128121
public static function isLibraryInternal(string $normalizedPath): bool
129122
{
@@ -165,86 +158,6 @@ public static function isLibraryInternal(string $normalizedPath): bool
165158
return false;
166159
}
167160

168-
/**
169-
* Fast-checks if an include glob list contains any pattern matching a given prefix.
170-
*
171-
* @param array<int, string> $includes
172-
*/
173-
public static function hasIncludeMatchingPrefix(string $prefix, array $includes): bool
174-
{
175-
$cacheKey = $prefix . '|' . implode(',', $includes);
176-
if (isset(self::$includePrefixCache[$cacheKey])) {
177-
return self::$includePrefixCache[$cacheKey];
178-
}
179-
180-
foreach ($includes as $inc) {
181-
if (\is_string($inc)) {
182-
$norm = str_replace('\\', '/', trim($inc));
183-
if (str_starts_with($norm, $prefix) || str_contains($norm, '/' . $prefix)) {
184-
return self::$includePrefixCache[$cacheKey] = true;
185-
}
186-
}
187-
}
188-
189-
return self::$includePrefixCache[$cacheKey] = false;
190-
}
191-
192-
/**
193-
* Determines whether a directory path is a dynamic writable cache/log directory.
194-
*/
195-
public static function isDynamicWritablePath(string $normalizedPath): bool
196-
{
197-
$canon = self::canonicalizePath($normalizedPath);
198-
199-
return str_contains($canon, '/var/cache/') || str_starts_with($canon, 'var/cache/')
200-
|| str_contains($canon, '/var/log/') || str_starts_with($canon, 'var/log/')
201-
|| str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')
202-
|| str_contains($canon, '/cache/') || str_starts_with($canon, 'cache/');
203-
}
204-
205-
/**
206-
* High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included,
207-
* while respecting user whitelists for vendor, var, and storage directories.
208-
*/
209-
public static function mayPathBeIncluded(string $normalizedPath): bool
210-
{
211-
$canon = self::canonicalizePath($normalizedPath);
212-
213-
if (str_contains($canon, '/node_modules/') || str_starts_with($canon, 'node_modules/')) {
214-
return false;
215-
}
216-
217-
if (self::isCachePath($canon)) {
218-
return false;
219-
}
220-
221-
$config = Config::get();
222-
/** @var array<int, string> $includes */
223-
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];
224-
225-
if (str_contains($canon, '/vendor/') || str_starts_with($canon, 'vendor/')) {
226-
if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) {
227-
return false;
228-
}
229-
}
230-
231-
// 4. var/ check: only inspect if user explicitly whitelisted var
232-
if (str_contains($canon, '/var/') || str_starts_with($canon, 'var/')) {
233-
if (! self::hasIncludeMatchingPrefix('var/', $includes)) {
234-
return false;
235-
}
236-
}
237-
238-
// 5. storage/ check: only inspect if user explicitly whitelisted storage
239-
if (str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')) {
240-
if (! self::hasIncludeMatchingPrefix('storage/', $includes)) {
241-
return false;
242-
}
243-
}
244-
245-
return true;
246-
}
247-
248161
/**
249162
* Converts a glob pattern into an absolute anchored regex pattern.
250163
*/
@@ -281,7 +194,6 @@ public static function isPathIncluded(
281194
?string $baseDir = null
282195
): bool {
283196
$baseDir = $baseDir !== null ? self::normalizePath($baseDir) : Config::getProjectRoot();
284-
$normalizedPath = self::canonicalizePath($normalizedPath);
285197
$normalizedRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : '';
286198

287199
$includes = self::getCompiledPatterns($includeGlobs, $baseDir, 'include');
@@ -291,8 +203,7 @@ public static function isPathIncluded(
291203
if ($isVendor) {
292204
$hasExplicitVendorWhitelist = false;
293205
foreach ($includes as $compiled) {
294-
if (
295-
str_starts_with($compiled['pattern'], 'vendor/') &&
206+
if (str_starts_with($compiled['pattern'], 'vendor/') &&
296207
(preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1))
297208
) {
298209
$hasExplicitVendorWhitelist = true;
@@ -369,4 +280,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin
369280

370281
return self::$compiledExcludesCache[$cacheKey] = $compiled;
371282
}
372-
}
283+
}

src/Internal/StreamWrapper.php

Lines changed: 29 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,6 @@ 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-
5951
/**
6052
* In-memory cache for isApplicationFile path decisions.
6153
*
@@ -83,7 +75,6 @@ final class StreamWrapper implements StreamWrapperInterface
8375
public static function reset(): void
8476
{
8577
self::$statCache = [];
86-
self::$staticNegativeStatCache = [];
8778
self::$appFileDecisionCache = [];
8879
PathMatcher::reset();
8980
}
@@ -194,47 +185,20 @@ public static function transformSource(string $source, string $filePath = ''): s
194185
*/
195186
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
196187
{
197-
if ($mode !== 'r' && $mode !== 'rb' && $mode !== 'rt') {
198-
return $this->openDirectHandle($path, $mode);
199-
}
200-
188+
// Fast-path: non-PHP files are never transformed
201189
if (! str_ends_with(strtolower($path), '.php')) {
202190
return $this->openDirectHandle($path, $mode);
203191
}
204192

205-
if (! Config::isEnabled()) {
206-
return $this->openDirectHandle($path, $mode);
207-
}
208-
209-
$normalizedRaw = str_replace('\\', '/', $path);
210-
211-
if (! PathMatcher::mayPathBeIncluded($normalizedRaw)) {
212-
return $this->openDirectHandle($path, $mode);
213-
}
214-
215-
if (isset(self::$appFileDecisionCache[$normalizedRaw])) {
216-
if (! self::$appFileDecisionCache[$normalizedRaw]) {
217-
return $this->openDirectHandle($path, $mode);
218-
}
219-
}
220-
221193
self::unregister();
222-
$exists = (bool) self::silent(fn () => file_exists($path));
223-
$resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false;
194+
$exists = self::silent(fn () => file_exists($path));
195+
$resolvedPath = $exists ? realpath($path) : '';
224196
self::register();
225197

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-
}
198+
$isAppFile = $exists && ! self::isReadOnlyCall() && self::isApplicationFile($path, $resolvedPath);
235199

236-
if (self::isReadOnlyCall()) {
237-
return $this->openDirectHandle($normalizedResolved, $mode);
200+
if (! $isAppFile || $resolvedPath === false) {
201+
return $this->openDirectHandle(($resolvedPath !== false && $resolvedPath !== '') ? $resolvedPath : $path, $mode);
238202
}
239203

240204
self::unregister();
@@ -248,20 +212,6 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
248212
return $success;
249213
}
250214

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-
265215
/**
266216
* Opens a raw file handle directly without stream interception.
267217
*/
@@ -396,36 +346,25 @@ public function stream_close(): void
396346

397347
/**
398348
* High-speed stat resolution with $O(1)$ memoization cache.
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.
349+
* Never caches false (negative lookups) so newly created directories and files are immediately discovered.
403350
*
404351
* @return array<int|string, int>|false
405352
*/
406353
public function url_stat(string $path, int $flags): array|false
407354
{
408355
$normalized = str_replace('\\', '/', $path);
409-
410356
if (isset(self::$statCache[$normalized])) {
411357
return self::$statCache[$normalized];
412358
}
413359

414-
if (isset(self::$staticNegativeStatCache[$normalized])) {
415-
return false;
416-
}
417-
418360
self::unregister();
419361
/** @var array<int|string, int>|false $result */
420362
$result = self::silent(fn () => stat($path));
421363
self::register();
422364

365+
// Only cache positive results (existing files/dirs)
423366
if ($result !== false) {
424367
self::$statCache[$normalized] = $result;
425-
} else {
426-
if (! PathMatcher::isDynamicWritablePath($normalized)) {
427-
self::$staticNegativeStatCache[$normalized] = true;
428-
}
429368
}
430369

431370
return $result;
@@ -434,7 +373,7 @@ public function url_stat(string $path, int $flags): array|false
434373
public function stream_metadata(string $path, int $option, mixed $value): bool
435374
{
436375
$normalized = str_replace('\\', '/', $path);
437-
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
376+
unset(self::$statCache[$normalized]);
438377

439378
self::unregister();
440379
$result = false;
@@ -498,10 +437,10 @@ public function dir_closedir(): bool
498437
public function mkdir(string $path, int $mode, int $options): bool
499438
{
500439
$normalized = str_replace('\\', '/', $path);
501-
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
440+
unset(self::$statCache[$normalized]);
502441

503442
self::unregister();
504-
$result = (bool) self::silent(fn () => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0));
443+
$result = (bool) self::silent(fn () => mkdir($path, $mode, (bool) ($options & STREAM_MKDIR_RECURSIVE)));
505444
self::register();
506445

507446
return $result;
@@ -510,7 +449,7 @@ public function mkdir(string $path, int $mode, int $options): bool
510449
public function rmdir(string $path, int $options): bool
511450
{
512451
$normalized = str_replace('\\', '/', $path);
513-
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
452+
unset(self::$statCache[$normalized]);
514453

515454
self::unregister();
516455
$result = (bool) self::silent(fn () => rmdir($path));
@@ -522,7 +461,7 @@ public function rmdir(string $path, int $options): bool
522461
public function unlink(string $path): bool
523462
{
524463
$normalized = str_replace('\\', '/', $path);
525-
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
464+
unset(self::$statCache[$normalized]);
526465

527466
self::unregister();
528467
$result = (bool) self::silent(fn () => unlink($path));
@@ -535,12 +474,7 @@ public function rename(string $pathFrom, string $pathTo): bool
535474
{
536475
$normFrom = str_replace('\\', '/', $pathFrom);
537476
$normTo = str_replace('\\', '/', $pathTo);
538-
unset(
539-
self::$statCache[$normFrom],
540-
self::$statCache[$normTo],
541-
self::$staticNegativeStatCache[$normFrom],
542-
self::$staticNegativeStatCache[$normTo]
543-
);
477+
unset(self::$statCache[$normFrom], self::$statCache[$normTo]);
544478

545479
self::unregister();
546480
$result = (bool) self::silent(fn () => rename($pathFrom, $pathTo));
@@ -549,6 +483,20 @@ public function rename(string $pathFrom, string $pathTo): bool
549483
return $result;
550484
}
551485

486+
/**
487+
* Determines if the current stream_open call is directly for reading file contents
488+
* rather than PHP engine's require/include execution.
489+
*/
490+
private static function isReadOnlyCall(): bool
491+
{
492+
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
493+
494+
$caller1 = strtolower($trace[1]['function'] ?? '');
495+
$caller2 = strtolower($trace[2]['function'] ?? '');
496+
497+
return isset(self::READ_ONLY_FUNCTIONS[$caller1]) || isset(self::READ_ONLY_FUNCTIONS[$caller2]);
498+
}
499+
552500
/**
553501
* Executes a callback while temporarily suppressing PHP error and warning handlers.
554502
*
@@ -715,4 +663,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat
715663

716664
SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs);
717665
}
718-
}
666+
}

0 commit comments

Comments
 (0)