From dc5bce6721469327521fdab8003f4cc0b90aa066 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 21 Aug 2026 01:45:32 +0800 Subject: [PATCH 1/5] Enhance PathMatcher and StreamWrapper with caching improvements; add tests for new functionality in PathMatcher and StreamWrapper --- src/Internal/PathMatcher.php | 84 ++++++++- src/Internal/StreamWrapper.php | 77 +++++++-- tests/Internal/PathMatcherTest.php | 84 ++++++++- tests/Internal/StreamWrapperTest.php | 246 ++++++++++++++++++++++----- 4 files changed, 426 insertions(+), 65 deletions(-) diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index 361a2a1..b92f913 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -23,6 +23,13 @@ final class PathMatcher */ private static array $compiledExcludesCache = []; + /** + * Cache for include prefix lookup decisions. + * + * @var array + */ + private static array $includePrefixCache = []; + /** * Cached normalized cache directory path. */ @@ -40,6 +47,7 @@ public static function reset(): void { self::$compiledIncludesCache = []; self::$compiledExcludesCache = []; + self::$includePrefixCache = []; self::$cachedCacheDir = null; self::$cachedLibSrcDir = null; } @@ -82,8 +90,6 @@ public static function isCachePath(string $normalizedPath): bool /** * Determines whether a path belongs to TypePHP's own internal engine source files. - * In vendor mode: skips the entire library package. - * In development mode: skips only actual internal subdirectories, allowing test fixtures to be tested. */ public static function isLibraryInternal(string $normalizedPath): bool { @@ -125,6 +131,80 @@ public static function isLibraryInternal(string $normalizedPath): bool return false; } + /** + * Fast-checks if an include glob list contains any pattern matching a given prefix. + * + * @param array $includes + */ + public static function hasIncludeMatchingPrefix(string $prefix, array $includes): bool + { + $cacheKey = $prefix . '|' . implode(',', $includes); + if (isset(self::$includePrefixCache[$cacheKey])) { + return self::$includePrefixCache[$cacheKey]; + } + + foreach ($includes as $inc) { + if (\is_string($inc)) { + $norm = str_replace('\\', '/', trim($inc)); + if (str_starts_with($norm, $prefix) || str_contains($norm, '/' . $prefix)) { + return self::$includePrefixCache[$cacheKey] = true; + } + } + } + + return self::$includePrefixCache[$cacheKey] = false; + } + + /** + * Determines whether a directory path is a dynamic writable cache/log directory. + */ + public static function isDynamicWritablePath(string $normalizedPath): bool + { + return str_contains($normalizedPath, '/var/cache/') || str_starts_with($normalizedPath, 'var/cache/') + || str_contains($normalizedPath, '/var/log/') || str_starts_with($normalizedPath, 'var/log/') + || str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/') + || str_contains($normalizedPath, '/cache/') || str_starts_with($normalizedPath, 'cache/'); + } + + /** + * High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included, + * while respecting user whitelists for vendor, var, and storage directories. + */ + public static function mayPathBeIncluded(string $normalizedPath): bool + { + if (str_contains($normalizedPath, '/node_modules/') || str_starts_with($normalizedPath, 'node_modules/')) { + return false; + } + + if (self::isCachePath($normalizedPath)) { + return false; + } + + $config = Config::get(); + /** @var array $includes */ + $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; + + if (str_contains($normalizedPath, '/vendor/') || str_starts_with($normalizedPath, 'vendor/')) { + if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) { + return false; + } + } + + if (str_contains($normalizedPath, '/var/') || str_starts_with($normalizedPath, 'var/')) { + if (! self::hasIncludeMatchingPrefix('var/', $includes)) { + return false; + } + } + + if (str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')) { + if (! self::hasIncludeMatchingPrefix('storage/', $includes)) { + return false; + } + } + + return true; + } + /** * Converts a glob pattern into an absolute anchored regex pattern. */ diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 18046ad..40ff859 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -48,6 +48,14 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $statCache = []; + /** + * In-memory cache for static-path negative misses only (e.g. vendor). + * Never stores negative misses for dynamic paths (var/cache, storage). + * + * @var array + */ + private static array $staticNegativeStatCache = []; + /** * In-memory cache for isApplicationFile path decisions. * @@ -61,6 +69,7 @@ final class StreamWrapper implements StreamWrapperInterface public static function reset(): void { self::$statCache = []; + self::$staticNegativeStatCache = []; self::$appFileDecisionCache = []; PathMatcher::reset(); } @@ -171,26 +180,43 @@ public static function transformSource(string $source, string $filePath = ''): s */ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { - // Fast-path: non-PHP files are never transformed + if ($mode !== 'r' && $mode !== 'rb' && $mode !== 'rt') { + return $this->openDirectHandle($path, $mode); + } + if (! str_ends_with(strtolower($path), '.php')) { return $this->openDirectHandle($path, $mode); } - // Fast-path: unwhitelisted vendor files are never transformed + if (! Config::isEnabled()) { + return $this->openDirectHandle($path, $mode); + } + $normalizedRaw = str_replace('\\', '/', $path); - if (str_contains($normalizedRaw, '/vendor/') || str_starts_with($normalizedRaw, 'vendor/')) { + + if (! PathMatcher::mayPathBeIncluded($normalizedRaw)) { return $this->openDirectHandle($path, $mode); } + if (isset(self::$appFileDecisionCache[$normalizedRaw])) { + if (! self::$appFileDecisionCache[$normalizedRaw]) { + return $this->openDirectHandle($path, $mode); + } + } + self::unregister(); - $exists = self::silent(fn () => file_exists($path)); - $resolvedPath = $exists ? realpath($path) : ''; + $exists = (bool) self::silent(fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; self::register(); - $isAppFile = $exists && ! self::isReadOnlyCall() && self::isApplicationFile($path, $resolvedPath); + if (! $exists || $resolvedPath === false) { + return $this->openDirectHandle($path, $mode); + } + + $normalizedResolved = str_replace('\\', '/', $resolvedPath); - if (! $isAppFile || $resolvedPath === false) { - return $this->openDirectHandle(($resolvedPath !== false && $resolvedPath !== '') ? $resolvedPath : $path, $mode); + if (! self::isApplicationFile($path, $resolvedPath)) { + return $this->openDirectHandle($normalizedResolved, $mode); } self::unregister(); @@ -338,25 +364,37 @@ public function stream_close(): void /** * High-speed stat resolution with $O(1)$ memoization cache. - * Never caches false (negative lookups) so newly created directories and files are immediately discovered. + * Caches positive stat hits. + * Only caches negative misses for STATIC directories (vendor, tests). + * NEVER caches negative misses for dynamic writable paths (var/cache, storage), + * guaranteeing Symfony/Shopware cache creation is detected immediately. * * @return array|false */ public function url_stat(string $path, int $flags): array|false { $normalized = str_replace('\\', '/', $path); + if (isset(self::$statCache[$normalized])) { return self::$statCache[$normalized]; } + + if (isset(self::$staticNegativeStatCache[$normalized])) { + return false; + } + self::unregister(); /** @var array|false $result */ $result = self::silent(fn () => stat($path)); self::register(); - // Only cache positive results (existing files/dirs) if ($result !== false) { self::$statCache[$normalized] = $result; + } else { + if (! PathMatcher::isDynamicWritablePath($normalized)) { + self::$staticNegativeStatCache[$normalized] = true; + } } return $result; @@ -365,7 +403,7 @@ public function url_stat(string $path, int $flags): array|false public function stream_metadata(string $path, int $option, mixed $value): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); $result = false; @@ -429,10 +467,10 @@ public function dir_closedir(): bool public function mkdir(string $path, int $mode, int $options): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn () => mkdir($path, $mode, (bool) ($options & STREAM_MKDIR_RECURSIVE))); + $result = (bool) self::silent(fn () => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); self::register(); return $result; @@ -441,7 +479,7 @@ public function mkdir(string $path, int $mode, int $options): bool public function rmdir(string $path, int $options): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); $result = (bool) self::silent(fn () => rmdir($path)); @@ -453,7 +491,7 @@ public function rmdir(string $path, int $options): bool public function unlink(string $path): bool { $normalized = str_replace('\\', '/', $path); - unset(self::$statCache[$normalized]); + unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); $result = (bool) self::silent(fn () => unlink($path)); @@ -466,7 +504,12 @@ public function rename(string $pathFrom, string $pathTo): bool { $normFrom = str_replace('\\', '/', $pathFrom); $normTo = str_replace('\\', '/', $pathTo); - unset(self::$statCache[$normFrom], self::$statCache[$normTo]); + unset( + self::$statCache[$normFrom], + self::$statCache[$normTo], + self::$staticNegativeStatCache[$normFrom], + self::$staticNegativeStatCache[$normTo] + ); self::unregister(); $result = (bool) self::silent(fn () => rename($pathFrom, $pathTo)); @@ -653,4 +696,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} +} \ No newline at end of file diff --git a/tests/Internal/PathMatcherTest.php b/tests/Internal/PathMatcherTest.php index 14f04e6..7b84a00 100644 --- a/tests/Internal/PathMatcherTest.php +++ b/tests/Internal/PathMatcherTest.php @@ -11,10 +11,12 @@ describe('PathMatcher Unit Tests', function () { beforeEach(function () { Config::reset(); + PathMatcher::reset(); }); afterEach(function () { Config::reset(); + PathMatcher::reset(); }); describe('normalizePath()', function () { @@ -88,6 +90,83 @@ }); }); + describe('hasIncludeMatchingPrefix()', function () { + test('detects when include list has patterns starting with prefix', function () { + $includes = ['src/**', 'vendor/my-org/my-pkg/**', 'app/**']; + + expect(PathMatcher::hasIncludeMatchingPrefix('vendor/', $includes))->toBeTrue() + ->and(PathMatcher::hasIncludeMatchingPrefix('src/', $includes))->toBeTrue() + ->and(PathMatcher::hasIncludeMatchingPrefix('var/', $includes))->toBeFalse() + ->and(PathMatcher::hasIncludeMatchingPrefix('storage/', $includes))->toBeFalse() + ; + }); + + test('detects scoped or subpath prefixes in include list', function () { + $includes = ['packages/custom/var/plugins/**']; + + expect(PathMatcher::hasIncludeMatchingPrefix('var/', $includes))->toBeTrue(); + }); + }); + + describe('isDynamicWritablePath()', function () { + test('identifies dynamic writable cache and log directories', function () { + expect(PathMatcher::isDynamicWritablePath('/var/www/var/cache/prod/Container.php'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('var/cache/test/app.php'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('var/log/dev.log'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('/project/storage/framework/views/123.php'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('storage/logs/laravel.log'))->toBeTrue() + ->and(PathMatcher::isDynamicWritablePath('/tmp/cache/item.php'))->toBeTrue() + ; + }); + + test('returns false for static read-only directories', function () { + expect(PathMatcher::isDynamicWritablePath('/var/www/src/Core/Service.php'))->toBeFalse() + ->and(PathMatcher::isDynamicWritablePath('/var/www/vendor/doctrine/dbal/Column.php'))->toBeFalse() + ->and(PathMatcher::isDynamicWritablePath('tests/Unit/SampleTest.php'))->toBeFalse() + ; + }); + }); + + describe('mayPathBeIncluded() Fast-Path String Pre-Filter', function () { + test('rejects node_modules and TypePHP cache unconditionally', function () { + $cacheDir = PathMatcher::normalizePath(CacheManager::getCacheDir()); + + expect(PathMatcher::mayPathBeIncluded('/var/www/node_modules/vue/index.js'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('node_modules/package/file.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded($cacheDir . '/v0.1_test.php'))->toBeFalse() + ; + }); + + test('rejects unwhitelisted vendor, var, and storage paths when config does not include them', function () { + Config::set([ + 'include' => ['src/**', 'app/**'], + ]); + + expect(PathMatcher::mayPathBeIncluded('vendor/monolog/monolog/src/Logger.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/vendor/symfony/console/App.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/var/cache/Container.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('storage/framework/views/1.php'))->toBeFalse() + ; + }); + + test('permits vendor, var, or storage paths when explicitly whitelisted in include config', function () { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/my-org/my-package/**', + 'var/plugins/**', + 'storage/custom/**', + ], + ]); + + expect(PathMatcher::mayPathBeIncluded('vendor/my-org/my-package/src/Service.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('/var/www/var/plugins/Plugin.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('storage/custom/Handler.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('src/App/Controller.php'))->toBeTrue() + ; + }); + }); + describe('compileGlobToRegex()', function () { test('compiles absolute glob patterns into exact anchored regex', function () { $baseDir = '/var/www/project'; @@ -185,8 +264,9 @@ }); describe('reset()', function () { - test('clears internal pattern and directory caches cleanly', function () { + test('clears internal pattern, prefix, and directory caches cleanly', function () { PathMatcher::normalizePath('test/path'); + PathMatcher::hasIncludeMatchingPrefix('vendor/', ['vendor/**']); PathMatcher::isCachePath('some/path'); PathMatcher::reset(); @@ -194,4 +274,4 @@ expect(true)->toBeTrue(); }); }); -}); +}); \ No newline at end of file diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index a650986..859c88e 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -2,13 +2,25 @@ declare(strict_types=1); +namespace TypePHP\Tests\Internal; + +use ReflectionClass; use TypePHP\Contract\FileFilter; use TypePHP\Internal\Config; use TypePHP\Internal\StreamWrapper; describe('StreamWrapper Unit Tests', function () { - test('transformSource transforms functions and injects RuntimeTypeChecker checks', function () { - $source = <<<'PHP' + beforeEach(function () { + Config::reset(); + }); + + afterEach(function () { + Config::reset(); + }); + + describe('transformSource()', function () { + test('transforms functions and injects RuntimeTypeChecker checks', function () { + $source = <<<'PHP' toContain('RuntimeTypeChecker::setupScope') - ->and($transformed)->toContain('testUser') - ; - }); + expect($transformed)->toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->toContain('testUser') + ; + }); - test('transformSource returns raw source unchanged if source is not valid PHP', function () { - $invalidSource = 'toBe($invalidSource); - }); + expect($transformed)->toBe($invalidSource); + }); - test('transformSource wraps yield expressions in generator functions', function () { - $genSource = <<<'PHP' + test('wraps yield expressions in generator functions', function () { + $genSource = <<<'PHP' toContain('RuntimeTypeChecker::checkYield') + ->and($transformed)->toContain('RuntimeTypeChecker::checkSend') + ; + }); + + test('respects @typephp-ignore-file docblock suppression tag', function () { + $source = <<<'PHP' +toContain('RuntimeTypeChecker::checkYield') - ->and($transformed)->toContain('RuntimeTypeChecker::checkSend') - ; + expect($transformed)->not()->toContain('RuntimeTypeChecker::setupScope') + ->and($transformed)->toBe($source) + ; + }); }); - test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { - Config::set([ - 'include' => [ - 'src/**', - 'src/Core/**', - 'src/Storefront/**', - 'src/Administration/**', - ], - 'exclude' => [ - 'vendor/**', - 'storage/**', - 'var/**', - 'cache/**', - ], - ]); - - $projectRoot = Config::getProjectRoot(); - - $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); - $appFile = str_replace('\\', '/', $projectRoot . '/src/Core/Framework/Util.php'); - - expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue() - ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() - ; + describe('url_stat() & Smart Negative Caching', function () { + test('caches positive stat results in memory', function () { + $wrapper = new StreamWrapper(); + $existingFile = __FILE__; - Config::reset(); + $stat1 = $wrapper->url_stat($existingFile, 0); + $stat2 = $wrapper->url_stat($existingFile, 0); + + expect($stat1)->toBeArray() + ->and($stat1)->toBe($stat2) + ; + }); + + test('caches negative misses for static vendor paths', function () { + $wrapper = new StreamWrapper(); + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $missingVendorFile = $projectRoot . '/vendor/non_existent_package/Missing.php'; + + $miss1 = $wrapper->url_stat($missingVendorFile, 0); + $miss2 = $wrapper->url_stat($missingVendorFile, 0); + + expect($miss1)->toBeFalse() + ->and($miss2)->toBeFalse() + ; + + $ref = new ReflectionClass(StreamWrapper::class); + $negProp = $ref->getProperty('staticNegativeStatCache'); + $negCache = $negProp->getValue(); + + expect($negCache)->toHaveKey($missingVendorFile); + }); + + test('never caches negative misses for dynamic writable paths (var/cache, storage)', function () { + $wrapper = new StreamWrapper(); + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $missingVarCacheFile = $projectRoot . '/var/cache/test/Container.php'; + + $miss = $wrapper->url_stat($missingVarCacheFile, 0); + + expect($miss)->toBeFalse(); + + $ref = new ReflectionClass(StreamWrapper::class); + $negProp = $ref->getProperty('staticNegativeStatCache'); + $negCache = $negProp->getValue(); + + expect($negCache)->not()->toHaveKey($missingVarCacheFile); + }); + + test('invalidates stat cache upon file mutation operations (mkdir, unlink, rename, touch)', function () { + $wrapper = new StreamWrapper(); + $tempDir = sys_get_temp_dir() . '/typephp_stat_test_' . uniqid(); + $tempFile = $tempDir . '/test.php'; + + $wrapper->mkdir($tempDir, 0777, STREAM_MKDIR_RECURSIVE); + file_put_contents($tempFile, 'url_stat($tempFile, 0); + expect($stat)->toBeArray(); + + $wrapper->stream_metadata($tempFile, STREAM_META_TOUCH, [time(), time()]); + + $renamedFile = $tempDir . '/renamed.php'; + $wrapper->rename($tempFile, $renamedFile); + + $wrapper->unlink($renamedFile); + $wrapper->rmdir($tempDir, 0); + + expect(true)->toBeTrue(); + }); + }); + + describe('stream_open() Fast-Paths & Whitelist Preservation', function () { + test('bypasses AST transformation on non-PHP files', function () { + $wrapper = new StreamWrapper(); + $openedPath = null; + $jsonFile = __DIR__ . '/../../composer.json'; + + $success = $wrapper->stream_open($jsonFile, 'r', 0, $openedPath); + expect($success)->toBeTrue(); + + $content = $wrapper->stream_read(1000); + expect($content)->toContain('"name": "typephp/typephp"'); + $wrapper->stream_close(); + }); + + test('bypasses AST transformation for unwhitelisted vendor files', function () { + Config::set([ + 'include' => ['src/**'], + 'exclude' => ['vendor/**'], + ]); + + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $vendorFile = $projectRoot . '/vendor/composer/autoload_real.php'; + + if (file_exists($vendorFile)) { + $wrapper = new StreamWrapper(); + $openedPath = null; + $success = $wrapper->stream_open($vendorFile, 'r', 0, $openedPath); + + expect($success)->toBeTrue(); + $wrapper->stream_close(); + } + }); + + test('transforms whitelisted vendor files when explicitly included in config', function () { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/monolog/monolog/src/Monolog/Logger.php', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $whitelistedVendorFile = $projectRoot . '/vendor/monolog/monolog/src/Monolog/Logger.php'; + + expect(FileFilter::isFileExcluded($whitelistedVendorFile))->toBeFalse(); + }); + }); + + describe('Vendor Subpackage Isolation', function () { + test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { + Config::set([ + 'include' => [ + 'src/**', + 'src/Core/**', + 'src/Storefront/**', + 'src/Administration/**', + ], + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], + ]); + + $projectRoot = Config::getProjectRoot(); + + $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); + $appFile = str_replace('\\', '/', $projectRoot . '/src/Core/Framework/Util.php'); + + expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue() + ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() + ; + }); }); }); From 51910fed85b00c93ef9e8a1eed577aaadace3b27 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 21 Aug 2026 01:53:40 +0800 Subject: [PATCH 2/5] Remove isReadOnlyCall method from StreamWrapper to streamline code --- src/Internal/StreamWrapper.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 40ff859..0cceb43 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -518,18 +518,6 @@ public function rename(string $pathFrom, string $pathTo): bool return $result; } - /** - * Determines if the current stream_open call is directly for reading file contents - * rather than PHP engine's require/include execution. - */ - private static function isReadOnlyCall(): bool - { - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); - $callerFunc = strtolower($trace[2]['function'] ?? ''); - - return \in_array($callerFunc, ['file_get_contents', 'file', 'readfile', 'highlight_file', 'show_source', 'token_get_all'], true); - } - /** * Executes a callback while temporarily suppressing PHP error and warning handlers. * From 74839bd004569931eea6d1a894f6b1161977c2f4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 21 Aug 2026 02:07:00 +0800 Subject: [PATCH 3/5] Add read-only function detection to StreamWrapper for optimized handling --- src/Internal/StreamWrapper.php | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 0cceb43..ca0ae31 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -63,6 +63,20 @@ final class StreamWrapper implements StreamWrapperInterface */ private static array $appFileDecisionCache = []; + /** + * Fast-lookup table for read-only source view functions. + * + * @var array + */ + private const READ_ONLY_FUNCTIONS = [ + 'file_get_contents' => true, + 'file' => true, + 'readfile' => true, + 'highlight_file' => true, + 'show_source' => true, + 'token_get_all' => true, + ]; + /** * Resets all internal caches. */ @@ -219,6 +233,10 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $this->openDirectHandle($normalizedResolved, $mode); } + if (self::isReadOnlyCall()) { + return $this->openDirectHandle($normalizedResolved, $mode); + } + self::unregister(); $success = self::$cacheEnabled @@ -230,6 +248,20 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $success; } + /** + * Determines if the stream_open call is for reading raw file contents/snippets + * (e.g. error screen renderers) rather than PHP engine execution. + */ + private static function isReadOnlyCall(): bool + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + + $caller1 = strtolower($trace[1]['function'] ?? ''); + $caller2 = strtolower($trace[2]['function'] ?? ''); + + return isset(self::READ_ONLY_FUNCTIONS[$caller1]) || isset(self::READ_ONLY_FUNCTIONS[$caller2]); + } + /** * Opens a raw file handle directly without stream interception. */ @@ -379,7 +411,6 @@ public function url_stat(string $path, int $flags): array|false return self::$statCache[$normalized]; } - if (isset(self::$staticNegativeStatCache[$normalized])) { return false; } From 0edc2bcf236aaba768ca416c998416914b5f3ee4 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Fri, 21 Aug 2026 02:08:49 +0800 Subject: [PATCH 4/5] Fix missing newline at end of file in StreamWrapper and PathMatcherTest --- src/Internal/StreamWrapper.php | 2 +- tests/Internal/PathMatcherTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index ca0ae31..0e5d96e 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -715,4 +715,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} \ No newline at end of file +} diff --git a/tests/Internal/PathMatcherTest.php b/tests/Internal/PathMatcherTest.php index 7b84a00..100afcd 100644 --- a/tests/Internal/PathMatcherTest.php +++ b/tests/Internal/PathMatcherTest.php @@ -274,4 +274,4 @@ expect(true)->toBeTrue(); }); }); -}); \ No newline at end of file +}); From 2c2b84186dd6aa4d889b65e429a0c823271d7e53 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 22 Aug 2026 00:02:44 +0800 Subject: [PATCH 5/5] Refactor PathMatcherTest and StreamWrapperTest to ensure Config resets after each test case --- tests/Internal/PathMatcherTest.php | 56 +++++++----- tests/Internal/StreamWrapperTest.php | 126 ++++++++++++++++----------- 2 files changed, 105 insertions(+), 77 deletions(-) diff --git a/tests/Internal/PathMatcherTest.php b/tests/Internal/PathMatcherTest.php index 100afcd..e730387 100644 --- a/tests/Internal/PathMatcherTest.php +++ b/tests/Internal/PathMatcherTest.php @@ -138,32 +138,40 @@ }); test('rejects unwhitelisted vendor, var, and storage paths when config does not include them', function () { - Config::set([ - 'include' => ['src/**', 'app/**'], - ]); - - expect(PathMatcher::mayPathBeIncluded('vendor/monolog/monolog/src/Logger.php'))->toBeFalse() - ->and(PathMatcher::mayPathBeIncluded('/var/www/vendor/symfony/console/App.php'))->toBeFalse() - ->and(PathMatcher::mayPathBeIncluded('/var/www/var/cache/Container.php'))->toBeFalse() - ->and(PathMatcher::mayPathBeIncluded('storage/framework/views/1.php'))->toBeFalse() - ; + try { + Config::set([ + 'include' => ['src/**', 'app/**'], + ]); + + expect(PathMatcher::mayPathBeIncluded('vendor/monolog/monolog/src/Logger.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/vendor/symfony/console/App.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/var/cache/Container.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('storage/framework/views/1.php'))->toBeFalse() + ; + } finally { + Config::reset(); + } }); test('permits vendor, var, or storage paths when explicitly whitelisted in include config', function () { - Config::set([ - 'include' => [ - 'src/**', - 'vendor/my-org/my-package/**', - 'var/plugins/**', - 'storage/custom/**', - ], - ]); - - expect(PathMatcher::mayPathBeIncluded('vendor/my-org/my-package/src/Service.php'))->toBeTrue() - ->and(PathMatcher::mayPathBeIncluded('/var/www/var/plugins/Plugin.php'))->toBeTrue() - ->and(PathMatcher::mayPathBeIncluded('storage/custom/Handler.php'))->toBeTrue() - ->and(PathMatcher::mayPathBeIncluded('src/App/Controller.php'))->toBeTrue() - ; + try { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/my-org/my-package/**', + 'var/plugins/**', + 'storage/custom/**', + ], + ]); + + expect(PathMatcher::mayPathBeIncluded('vendor/my-org/my-package/src/Service.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('/var/www/var/plugins/Plugin.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('storage/custom/Handler.php'))->toBeTrue() + ->and(PathMatcher::mayPathBeIncluded('src/App/Controller.php'))->toBeTrue() + ; + } finally { + Config::reset(); + } }); }); @@ -274,4 +282,4 @@ expect(true)->toBeTrue(); }); }); -}); +}); \ No newline at end of file diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index 859c88e..1247d8d 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -164,6 +164,10 @@ function testIgnoredFileFunc(int $id): int }); describe('stream_open() Fast-Paths & Whitelist Preservation', function () { + afterEach(function () { + Config::reset(); + }); + test('bypasses AST transformation on non-PHP files', function () { $wrapper = new StreamWrapper(); $openedPath = null; @@ -178,67 +182,83 @@ function testIgnoredFileFunc(int $id): int }); test('bypasses AST transformation for unwhitelisted vendor files', function () { - Config::set([ - 'include' => ['src/**'], - 'exclude' => ['vendor/**'], - ]); - - $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); - $vendorFile = $projectRoot . '/vendor/composer/autoload_real.php'; - - if (file_exists($vendorFile)) { - $wrapper = new StreamWrapper(); - $openedPath = null; - $success = $wrapper->stream_open($vendorFile, 'r', 0, $openedPath); - - expect($success)->toBeTrue(); - $wrapper->stream_close(); + try { + Config::set([ + 'include' => ['src/**'], + 'exclude' => ['vendor/**'], + ]); + + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $vendorFile = $projectRoot . '/vendor/composer/autoload_real.php'; + + if (file_exists($vendorFile)) { + $wrapper = new StreamWrapper(); + $openedPath = null; + $success = $wrapper->stream_open($vendorFile, 'r', 0, $openedPath); + + expect($success)->toBeTrue(); + $wrapper->stream_close(); + } + } finally { + Config::reset(); } }); test('transforms whitelisted vendor files when explicitly included in config', function () { - Config::set([ - 'include' => [ - 'src/**', - 'vendor/monolog/monolog/src/Monolog/Logger.php', - ], - 'exclude' => [ - 'vendor/**', - ], - ]); - - $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); - $whitelistedVendorFile = $projectRoot . '/vendor/monolog/monolog/src/Monolog/Logger.php'; - - expect(FileFilter::isFileExcluded($whitelistedVendorFile))->toBeFalse(); + try { + Config::set([ + 'include' => [ + 'src/**', + 'vendor/monolog/monolog/src/Monolog/Logger.php', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + $projectRoot = str_replace('\\', '/', Config::getProjectRoot()); + $whitelistedVendorFile = $projectRoot . '/vendor/monolog/monolog/src/Monolog/Logger.php'; + + expect(FileFilter::isFileExcluded($whitelistedVendorFile))->toBeFalse(); + } finally { + Config::reset(); + } }); }); describe('Vendor Subpackage Isolation', function () { + afterEach(function () { + Config::reset(); + }); + test('strictly isolates vendor files with nested src directories when application includes specific src subpackages', function () { - Config::set([ - 'include' => [ - 'src/**', - 'src/Core/**', - 'src/Storefront/**', - 'src/Administration/**', - ], - 'exclude' => [ - 'vendor/**', - 'storage/**', - 'var/**', - 'cache/**', - ], - ]); - - $projectRoot = Config::getProjectRoot(); - - $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); - $appFile = str_replace('\\', '/', $projectRoot . '/src/Core/Framework/Util.php'); - - expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue() - ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() - ; + try { + Config::set([ + 'include' => [ + 'src/**', + 'src/Core/**', + 'src/Storefront/**', + 'src/Administration/**', + ], + 'exclude' => [ + 'vendor/**', + 'storage/**', + 'var/**', + 'cache/**', + ], + ]); + + $projectRoot = Config::getProjectRoot(); + + $vendorFile = str_replace('\\', '/', $projectRoot . '/vendor/doctrine/dbal/src/Core/Table.php'); + $appFile = str_replace('\\', '/', $projectRoot . '/src/Core/Framework/Util.php'); + + expect(FileFilter::isFileExcluded($vendorFile))->toBeTrue() + ->and(FileFilter::isFileExcluded($appFile))->toBeFalse() + ; + } finally { + Config::reset(); + } }); }); -}); +}); \ No newline at end of file