diff --git a/compiler/build/scoper-namespaces.php b/compiler/build/scoper-namespaces.php index e147c285245..ad11951adcb 100644 --- a/compiler/build/scoper-namespaces.php +++ b/compiler/build/scoper-namespaces.php @@ -43,4 +43,21 @@ 'Foobar', 'PDO', ], + + /** + * Files that refer to the analysed project's Composer\Autoload\ClassLoader, + * not to the phar's own prefixed copy - an instanceof against the prefixed + * name never matches the project's autoloader. + * + * A patcher in scoper.inc.php strips the prefix back off in these files. + * ScoperComposerClassLoaderTest fails when a file in src/ or bin/ refers to + * the class without being listed here. + */ + 'unprefixedComposerClassLoaderIn' => [ + 'bin/phpstan', + 'src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php', + 'src/Testing/TestCaseSourceLocatorFactory.php', + 'src/Testing/PHPStanTestCase.php', + 'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php', + ], ]; diff --git a/compiler/build/scoper.inc.php b/compiler/build/scoper.inc.php index 6407e29f8c4..4ca77940a45 100644 --- a/compiler/build/scoper.inc.php +++ b/compiler/build/scoper.inc.php @@ -140,13 +140,8 @@ function (string $filePath, string $prefix, string $content): string { return \Nette\Neon\Neon::encode($updatedNeon, \Nette\Neon\Neon::BLOCK); }, - function (string $filePath, string $prefix, string $content): string { - if (!in_array($filePath, [ - 'bin/phpstan', - 'src/Testing/TestCaseSourceLocatorFactory.php', - 'src/Testing/PHPStanTestCase.php', - 'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php', - ], true)) { + function (string $filePath, string $prefix, string $content) use ($namespaces): string { + if (!in_array($filePath, $namespaces['unprefixedComposerClassLoaderIn'], true)) { return $content; } diff --git a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php index 0a5a61c3793..4216ec4b86e 100644 --- a/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; +use Composer\Autoload\ClassLoader; use Override; use ParseError; use PhpParser\Node\Arg; @@ -31,12 +32,15 @@ use function defined; use function function_exists; use function interface_exists; +use function is_array; use function is_file; use function is_string; use function opcache_invalidate; +use function realpath; use function restore_error_handler; use function set_error_handler; use function spl_autoload_functions; +use function str_contains; use function strtolower; use function trait_exists; use const PHP_VERSION_ID; @@ -333,58 +337,107 @@ private function locateClassByName(string $className): ?array return null; } + $autoloadFunctions = spl_autoload_functions(); + if ($autoloadFunctions === false) { + return null; + } + $this->silenceErrors(); try { - $result = FileReadTrapStreamWrapper::withStreamWrapperOverride( - static function () use ($className): ?array { - $functions = spl_autoload_functions(); - if ($functions === false) { - return null; + return self::locateThroughAutoloaders($autoloadFunctions, $className); + } finally { + restore_error_handler(); + } + } + + /** + * Runs the registered autoloaders, in order, to find which file declares a + * class - asking Composer's where it would look instead of letting it get + * there by including the file. + * + * ClassLoader::findFile() answers with the same path loadClass() would + * include, without running anything. Nothing is compiled, so the file cannot + * execute a second time - which it otherwise does whenever OPcache already + * holds the script: the include is then served from the cache without the + * trap being asked for the contents at all, and a file declaring a function + * fatals with "Cannot redeclare". That is the function-per-file layout of + * php-standard-library and azjezz/psl, whose files-autoload bootstrap has + * already loaded every path their PSR-4 prefix also resolves to. + * + * Every other autoloader still runs inside the trap, one at a time so that + * registration order is preserved either way: an autoloader ahead of + * Composer's may well claim a name Composer would resolve elsewhere. + * + * So does a ClassLoader whose answer lies behind a stream wrapper. The phar's + * own autoloader is one - Box leaves Composer's ClassLoader unprefixed, and it + * maps PHPStan\ to phar://.../src - and realpath() cannot resolve such a path, + * while the trap has always recorded them. The OPcache hazard above is about + * files on disk. + * + * @param list $autoloadFunctions + * @return array{string[], string, int|null}|null + */ + private static function locateThroughAutoloaders(array $autoloadFunctions, string $className): ?array + { + foreach ($autoloadFunctions as $autoloadFunction) { + if (is_array($autoloadFunction) && $autoloadFunction[0] instanceof ClassLoader) { + $file = $autoloadFunction[0]->findFile($className); + if ($file === false) { + continue; + } + + if (!str_contains($file, '://')) { + // findFile() concatenates the mapped prefix with the rest of + // the name, so its answer can carry ../ segments and mixed + // separators. PHP resolves an include path before the trap + // ever sees it, and locateIdentifier() matches what lands here + // against ReflectionClass::getFileName(), which is resolved too. + $resolvedFile = realpath($file); + + // a class map can outlive the file it points at, and + // loadClass() would move on to the next autoloader just the same + if ($resolvedFile === false || !is_file($resolvedFile)) { + continue; } - foreach ($functions as $preExistingAutoloader) { - try { - $preExistingAutoloader($className); - } catch (ParseError) { - // the trap served a parse error instead of the empty - // script, see FileReadTrapStreamWrapper::stream_read(); - // the file was recorded before the include compiled it - } + return [[$resolvedFile], $className, null]; + } + } - /** - * This static variable is populated by the side-effect of the stream wrapper - * trying to read the file path when `include()` is used by an autoloader. - * - * This will not be `null` when the autoloader tried to read a file. - */ - if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) { - return [FileReadTrapStreamWrapper::$autoloadLocatedFiles, $className, null]; - } + $locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride( + static function () use ($autoloadFunction, $className): array { + try { + $autoloadFunction($className); + } catch (ParseError) { + // the trap served a parse error instead of the empty + // script, see FileReadTrapStreamWrapper::stream_read(); + // the file was recorded before the include compiled it } - return null; + // populated by the side effect of the stream wrapper being + // asked for the file an include() reached for + return FileReadTrapStreamWrapper::$autoloadLocatedFiles; }, ); - if ($result === null) { - return null; - } - if (!function_exists('opcache_invalidate')) { - return $result; + if ($locatedFiles === []) { + continue; } // the trap's empty script got compiled - and cached, with OPcache // active. Where this call cannot reach the entry, the trap served a // parse error instead, see FileReadTrapStreamWrapper::stream_read() - foreach ($result[0] as $file) { - opcache_invalidate($file, true); + if (function_exists('opcache_invalidate')) { + foreach ($locatedFiles as $locatedFile) { + opcache_invalidate($locatedFile, true); + } } - return $result; - } finally { - restore_error_handler(); + return [$locatedFiles, $className, null]; } + + return null; } private function silenceErrors(): void diff --git a/tests/PHPStan/Build/ScoperComposerClassLoaderTest.php b/tests/PHPStan/Build/ScoperComposerClassLoaderTest.php new file mode 100644 index 00000000000..455a1075951 --- /dev/null +++ b/tests/PHPStan/Build/ScoperComposerClassLoaderTest.php @@ -0,0 +1,70 @@ +} $namespaces */ + $namespaces = require __DIR__ . '/../../../compiler/build/scoper-namespaces.php'; + + $root = realpath(__DIR__ . '/../../..'); + if ($root === false) { + self::fail('Could not resolve the repository root.'); + } + + $files = [$root . '/bin/phpstan']; + $finder = new Finder(); + $finder->followLinks(); + foreach ($finder->files()->name('*.php')->in($root . '/src') as $fileInfo) { + $files[] = $fileInfo->getPathname(); + } + + foreach ($files as $file) { + $code = file_get_contents($file); + if ($code === false) { + self::fail(sprintf('Could not read %s', $file)); + } + + if (!str_contains($code, 'Composer\Autoload\ClassLoader')) { + continue; + } + + $relativePath = str_replace('\\', '/', substr($file, strlen($root) + 1)); + if (in_array($relativePath, $namespaces['unprefixedComposerClassLoaderIn'], true)) { + continue; + } + + self::fail(sprintf( + '%s refers to Composer\\Autoload\\ClassLoader. php-scoper prefixes the reference in the phar, ' + . "where it no longer matches the analysed project's autoloader, so the file has to be added to " + . "'unprefixedComposerClassLoaderIn' in compiler/build/scoper-namespaces.php.", + $relativePath, + )); + } + + self::expectNotToPerformAssertions(); + } + +} diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php index 522bc15e224..02560e3ce01 100644 --- a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php @@ -2,6 +2,10 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; +use Composer\Autoload\ClassLoader; +use Phar; +use PharData; +use PharException; use PHPStan\BetterReflection\Reflection\ReflectionClass; use PHPStan\BetterReflection\Reflector\DefaultReflector; use PHPStan\Reflection\InitializerExprContext; @@ -12,6 +16,8 @@ use TestSingleFileSourceLocator\InCondition; use function array_merge; use function class_alias; +use function sys_get_temp_dir; +use function uniqid; function testFunctionForLocator(): void // phpcs:disable { @@ -79,6 +85,40 @@ class_alias(AFoo::class, 'A_Foo'); $this->assertSame(AFoo::class, $class->getName()); } + /** + * PHPStan's own classes live in the phar, behind a Composer ClassLoader that + * maps PHPStan\ to phar://.../src - and realpath() cannot resolve such a path. + * A tar archive stands in for the phar: PharData writes one even with + * phar.readonly on, and phar:// reads it the same way. + * + * @throws PharException + */ + public function testClassInsidePharBehindComposerClassLoader(): void + { + $archive = sys_get_temp_dir() . '/phpstan-autoload-source-locator-' . uniqid() . '.tar'; + $pharData = new PharData($archive); + $pharData->addFromString('src/InPhar.php', "addPsr4('AutoloadSourceLocatorInPhar\\', ['phar://' . $archive . '/src']); + $loader->register(true); + + try { + $locator = new AutoloadSourceLocator(self::getContainer()->getByType(FileNodesFetcher::class), true); + $reflector = new DefaultReflector($locator); + $class = $reflector->reflectClass('AutoloadSourceLocatorInPhar\InPhar'); + $this->assertSame('InPhar', $class->getShortName()); + + $fileName = $class->getFileName(); + $this->assertNotNull($fileName); + $this->assertStringStartsWith('phar://', $fileName); + } finally { + $loader->unregister(); + Phar::unlinkArchive($archive); + } + } + public static function getAdditionalConfigFiles(): array { return array_merge( diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php index cd1a78087bc..00b288df605 100644 --- a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/FileReadTrapStreamWrapperTest.php @@ -3,7 +3,16 @@ namespace PHPStan\Reflection\BetterReflection\SourceLocator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use function escapeshellarg; +use function exec; +use function explode; +use function extension_loaded; +use function implode; +use function sprintf; +use function str_contains; +use const PHP_BINARY; final class FileReadTrapStreamWrapperTest extends TestCase { @@ -29,4 +38,50 @@ public function testResolveServesParseError(int $phpVersionId, bool $opcacheEnab $this->assertSame($expected, FileReadTrapStreamWrapper::resolveServesParseError($phpVersionId, $opcacheEnabled, $path)); } + /** + * Probing a name whose PSR-4 prefix resolves to a file the process already + * ran must not run it again: with OPcache holding the script, an include is + * served from the cache without the trap being asked for the contents. A name + * whose file nothing has loaded must still resolve, without executing it. + * + * Needs its own process: OPcache is only on in the processes PHPStan spawns + * for itself, and the failure is a fatal error. + */ + #[Group('exec')] + public function testTrapSurvivesOpcacheCacheHit(): void + { + if (!extension_loaded('Zend OPcache')) { + self::markTestSkipped('OPcache is not available.'); + } + + exec(sprintf( + '%s -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.validate_timestamps=0 %s 2>&1', + escapeshellarg(PHP_BINARY), + escapeshellarg(__DIR__ . '/data/opcache-trap/driver.php'), + ), $outputLines, $exitCode); + $output = implode("\n", $outputLines); + + $this->assertSame(0, $exitCode, $output); + + $values = []; + foreach ($outputLines as $outputLine) { + if (!str_contains($outputLine, '=')) { + continue; + } + [$name, $value] = explode('=', $outputLine, 2); + $values[$name] = $value; + } + + // hold whether or not OPcache could be turned on + $this->assertSame('1', $values['survivedLoadedProbe'] ?? null, $output); + $this->assertSame('1', $values['resolvedCold'] ?? null, $output); + $this->assertSame('1', $values['coldFileNotExecuted'] ?? null, $output); + + if (($values['opcacheEnabled'] ?? '0') === '1') { + return; + } + + self::markTestSkipped('OPcache could not be enabled for the CLI, so the cache hit was not exercised.'); + } + } diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/opcache-trap/ColdClass.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/opcache-trap/ColdClass.php new file mode 100644 index 00000000000..b8efd683567 --- /dev/null +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/opcache-trap/ColdClass.php @@ -0,0 +1,13 @@ +addPsr4('OpcacheTrap\\', [__DIR__]); + +$locator = new AutoloadSourceLocator( + PHPStanTestCase::getContainer()->getByType(FileNodesFetcher::class), + true, +); +$reflector = new DefaultReflector($locator); + +// OpcacheTrap\thing is a function, not a class, but PHPStan probes the name as +// a class the same way it does for Psl\Type\optional - and the PSR-4 prefix +// sends the autoloader at the already-loaded function.php +$locator->locateIdentifier($reflector, new Identifier('OpcacheTrap\thing', new IdentifierType(IdentifierType::IDENTIFIER_CLASS))); +echo "survivedLoadedProbe=1\n"; + +// a name whose file nothing has loaded must still resolve +$cold = $reflector->reflectClass('OpcacheTrap\ColdClass'); +echo 'resolvedCold=', $cold->getName() === 'OpcacheTrap\ColdClass' ? '1' : '0', "\n"; +echo 'coldFileNotExecuted=', !function_exists('OpcacheTrap\coldThing') ? '1' : '0', "\n"; diff --git a/tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/opcache-trap/thing.php b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/opcache-trap/thing.php new file mode 100644 index 00000000000..03ce0d9c529 --- /dev/null +++ b/tests/PHPStan/Reflection/BetterReflection/SourceLocator/data/opcache-trap/thing.php @@ -0,0 +1,10 @@ +