Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions compiler/build/scoper-namespaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
];
9 changes: 2 additions & 7 deletions compiler/build/scoper.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace PHPStan\Reflection\BetterReflection\SourceLocator;

use Composer\Autoload\ClassLoader;
use Override;
use ParseError;
use PhpParser\Node\Arg;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<callable(string): void> $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
Expand Down
70 changes: 70 additions & 0 deletions tests/PHPStan/Build/ScoperComposerClassLoaderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php declare(strict_types = 1);

namespace PHPStan\Build;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
use function file_get_contents;
use function in_array;
use function realpath;
use function sprintf;
use function str_contains;
use function str_replace;
use function strlen;
use function substr;

/**
* Code in src/ and bin/ that recognises the analysed project's autoloader - an
* `instanceof ClassLoader` in AutoloadSourceLocator, say - has to name the
* unprefixed Composer\Autoload\ClassLoader. php-scoper prefixes the reference in
* the phar, where it then names the phar's own copy and never matches the
* project's autoloader, unless a patcher in compiler/build/scoper.inc.php
* strips the prefix back off in that file.
*/
final class ScoperComposerClassLoaderTest extends TestCase
{

public function testClassLoaderReferencesAreNotPrefixedInPhar(): void
{
/** @var array{unprefixedComposerClassLoaderIn: list<string>} $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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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', "<?php\n\nnamespace AutoloadSourceLocatorInPhar;\n\nclass InPhar\n{\n\n}\n");
unset($pharData);

$loader = new ClassLoader();
$loader->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(
Expand Down
Loading
Loading