Skip to content

Commit 34f08f3

Browse files
theodorejbclaude
andcommitted
Ask Composer's autoloaders for the file instead of letting them include it
AutoloadSourceLocator finds which file declares a class by running the registered autoloaders behind FileReadTrapStreamWrapper, which records the path an include reached for and serves an empty script in its place. That only shadows the real file while the compiler asks the wrapper for the contents. With OPcache already holding the script it does not ask, and the file runs a second time - fatal for one declaring a function, which is the function-per-file layout of php-standard-library and azjezz/psl: their files-autoload bootstrap has already loaded every path their PSR-4 prefix also resolves to. ClassLoader::findFile() answers with the same path loadClass() would include, without running anything, so ask it rather than arranging for the include to be harmless. Nothing is compiled and no cache is consulted, which is what makes this hold wherever PHP runs. Autoloaders still run in registration order: every non-Composer one runs inside the trap as before, one at a time, since an autoloader ahead of Composer's may claim a name Composer would resolve elsewhere. Running them one at a time also stops hoa/compiler's autoloader, registered ahead of the analysed project's loader, from forcing the whole probe down the include path. findFile() concatenates the mapped prefix with the rest of the name, so its answer can carry ../ segments and mixed separators, where PHP resolves an include path before the trap ever sees it. It is resolved here to match, which locateIdentifier() relies on when it compares the located path against ReflectionClass::getFileName() to tell two same-named classes in one file apart. In the phar, php-scoper prefixes the Composer\Autoload\ClassLoader import, but Box leaves the class itself unprefixed, so the prefixed name matches no autoloader at all. scoper.inc.php already strips that prefix back off in a fixed list of files; AutoloadSourceLocator.php joins it, and the list moves into scoper-namespaces.php so that ScoperComposerClassLoaderTest can fail when a file in src/ or bin/phpstan refers to the class without being listed. The phar's own autoloader is therefore a ClassLoader too, and it maps PHPStan\ to phar://.../src. realpath() cannot resolve a path behind a stream wrapper, so a ClassLoader that answers with one is left to the trap, which has always recorded such paths - otherwise PHPStan's own classes that are not loaded yet, PHPStan\TrinaryLogic among them, go missing when analysing code that uses them. The OPcache hazard is about files on disk. The OPcache test drives the real locator in a subprocess under the worker's own OPcache flags: a name whose PSR-4 prefix resolves to a file the process already ran, which must not run it again, and one whose file nothing has loaded, which must still resolve without executing. It runs from source, so the scoping above is left to ScoperComposerClassLoaderTest. AutoloadSourceLocatorTest covers a class inside a phar behind a ClassLoader, using a tar archive that PharData can write with phar.readonly on. Closes phpstan/phpstan#15184 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e187945 commit 34f08f3

9 files changed

Lines changed: 345 additions & 40 deletions

File tree

‎compiler/build/scoper-namespaces.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,21 @@
4343
'Foobar',
4444
'PDO',
4545
],
46+
47+
/**
48+
* Files that refer to the analysed project's Composer\Autoload\ClassLoader,
49+
* not to the phar's own prefixed copy - an instanceof against the prefixed
50+
* name never matches the project's autoloader.
51+
*
52+
* A patcher in scoper.inc.php strips the prefix back off in these files.
53+
* ScoperComposerClassLoaderTest fails when a file in src/ or bin/ refers to
54+
* the class without being listed here.
55+
*/
56+
'unprefixedComposerClassLoaderIn' => [
57+
'bin/phpstan',
58+
'src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php',
59+
'src/Testing/TestCaseSourceLocatorFactory.php',
60+
'src/Testing/PHPStanTestCase.php',
61+
'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php',
62+
],
4663
];

‎compiler/build/scoper.inc.php‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,8 @@ function (string $filePath, string $prefix, string $content): string {
140140

141141
return \Nette\Neon\Neon::encode($updatedNeon, \Nette\Neon\Neon::BLOCK);
142142
},
143-
function (string $filePath, string $prefix, string $content): string {
144-
if (!in_array($filePath, [
145-
'bin/phpstan',
146-
'src/Testing/TestCaseSourceLocatorFactory.php',
147-
'src/Testing/PHPStanTestCase.php',
148-
'vendor/ondrejmirtes/better-reflection/src/SourceLocator/Type/ComposerSourceLocator.php',
149-
], true)) {
143+
function (string $filePath, string $prefix, string $content) use ($namespaces): string {
144+
if (!in_array($filePath, $namespaces['unprefixedComposerClassLoaderIn'], true)) {
150145
return $content;
151146
}
152147

‎src/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocator.php‎

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
44

5+
use Composer\Autoload\ClassLoader;
56
use Override;
67
use ParseError;
78
use PhpParser\Node\Arg;
@@ -31,12 +32,15 @@
3132
use function defined;
3233
use function function_exists;
3334
use function interface_exists;
35+
use function is_array;
3436
use function is_file;
3537
use function is_string;
3638
use function opcache_invalidate;
39+
use function realpath;
3740
use function restore_error_handler;
3841
use function set_error_handler;
3942
use function spl_autoload_functions;
43+
use function str_contains;
4044
use function strtolower;
4145
use function trait_exists;
4246
use const PHP_VERSION_ID;
@@ -333,58 +337,107 @@ private function locateClassByName(string $className): ?array
333337
return null;
334338
}
335339

340+
$autoloadFunctions = spl_autoload_functions();
341+
if ($autoloadFunctions === false) {
342+
return null;
343+
}
344+
336345
$this->silenceErrors();
337346

338347
try {
339-
$result = FileReadTrapStreamWrapper::withStreamWrapperOverride(
340-
static function () use ($className): ?array {
341-
$functions = spl_autoload_functions();
342-
if ($functions === false) {
343-
return null;
348+
return self::locateThroughAutoloaders($autoloadFunctions, $className);
349+
} finally {
350+
restore_error_handler();
351+
}
352+
}
353+
354+
/**
355+
* Runs the registered autoloaders, in order, to find which file declares a
356+
* class - asking Composer's where it would look instead of letting it get
357+
* there by including the file.
358+
*
359+
* ClassLoader::findFile() answers with the same path loadClass() would
360+
* include, without running anything. Nothing is compiled, so the file cannot
361+
* execute a second time - which it otherwise does whenever OPcache already
362+
* holds the script: the include is then served from the cache without the
363+
* trap being asked for the contents at all, and a file declaring a function
364+
* fatals with "Cannot redeclare". That is the function-per-file layout of
365+
* php-standard-library and azjezz/psl, whose files-autoload bootstrap has
366+
* already loaded every path their PSR-4 prefix also resolves to.
367+
*
368+
* Every other autoloader still runs inside the trap, one at a time so that
369+
* registration order is preserved either way: an autoloader ahead of
370+
* Composer's may well claim a name Composer would resolve elsewhere.
371+
*
372+
* So does a ClassLoader whose answer lies behind a stream wrapper. The phar's
373+
* own autoloader is one - Box leaves Composer's ClassLoader unprefixed, and it
374+
* maps PHPStan\ to phar://.../src - and realpath() cannot resolve such a path,
375+
* while the trap has always recorded them. The OPcache hazard above is about
376+
* files on disk.
377+
*
378+
* @param list<callable(string): void> $autoloadFunctions
379+
* @return array{string[], string, int|null}|null
380+
*/
381+
private static function locateThroughAutoloaders(array $autoloadFunctions, string $className): ?array
382+
{
383+
foreach ($autoloadFunctions as $autoloadFunction) {
384+
if (is_array($autoloadFunction) && $autoloadFunction[0] instanceof ClassLoader) {
385+
$file = $autoloadFunction[0]->findFile($className);
386+
if ($file === false) {
387+
continue;
388+
}
389+
390+
if (!str_contains($file, '://')) {
391+
// findFile() concatenates the mapped prefix with the rest of
392+
// the name, so its answer can carry ../ segments and mixed
393+
// separators. PHP resolves an include path before the trap
394+
// ever sees it, and locateIdentifier() matches what lands here
395+
// against ReflectionClass::getFileName(), which is resolved too.
396+
$resolvedFile = realpath($file);
397+
398+
// a class map can outlive the file it points at, and
399+
// loadClass() would move on to the next autoloader just the same
400+
if ($resolvedFile === false || !is_file($resolvedFile)) {
401+
continue;
344402
}
345403

346-
foreach ($functions as $preExistingAutoloader) {
347-
try {
348-
$preExistingAutoloader($className);
349-
} catch (ParseError) {
350-
// the trap served a parse error instead of the empty
351-
// script, see FileReadTrapStreamWrapper::stream_read();
352-
// the file was recorded before the include compiled it
353-
}
404+
return [[$resolvedFile], $className, null];
405+
}
406+
}
354407

355-
/**
356-
* This static variable is populated by the side-effect of the stream wrapper
357-
* trying to read the file path when `include()` is used by an autoloader.
358-
*
359-
* This will not be `null` when the autoloader tried to read a file.
360-
*/
361-
if (FileReadTrapStreamWrapper::$autoloadLocatedFiles !== []) {
362-
return [FileReadTrapStreamWrapper::$autoloadLocatedFiles, $className, null];
363-
}
408+
$locatedFiles = FileReadTrapStreamWrapper::withStreamWrapperOverride(
409+
static function () use ($autoloadFunction, $className): array {
410+
try {
411+
$autoloadFunction($className);
412+
} catch (ParseError) {
413+
// the trap served a parse error instead of the empty
414+
// script, see FileReadTrapStreamWrapper::stream_read();
415+
// the file was recorded before the include compiled it
364416
}
365417

366-
return null;
418+
// populated by the side effect of the stream wrapper being
419+
// asked for the file an include() reached for
420+
return FileReadTrapStreamWrapper::$autoloadLocatedFiles;
367421
},
368422
);
369-
if ($result === null) {
370-
return null;
371-
}
372423

373-
if (!function_exists('opcache_invalidate')) {
374-
return $result;
424+
if ($locatedFiles === []) {
425+
continue;
375426
}
376427

377428
// the trap's empty script got compiled - and cached, with OPcache
378429
// active. Where this call cannot reach the entry, the trap served a
379430
// parse error instead, see FileReadTrapStreamWrapper::stream_read()
380-
foreach ($result[0] as $file) {
381-
opcache_invalidate($file, true);
431+
if (function_exists('opcache_invalidate')) {
432+
foreach ($locatedFiles as $locatedFile) {
433+
opcache_invalidate($locatedFile, true);
434+
}
382435
}
383436

384-
return $result;
385-
} finally {
386-
restore_error_handler();
437+
return [$locatedFiles, $className, null];
387438
}
439+
440+
return null;
388441
}
389442

390443
private function silenceErrors(): void
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Build;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Symfony\Component\Finder\Finder;
7+
use function file_get_contents;
8+
use function in_array;
9+
use function realpath;
10+
use function sprintf;
11+
use function str_contains;
12+
use function str_replace;
13+
use function strlen;
14+
use function substr;
15+
16+
/**
17+
* Code in src/ and bin/ that recognises the analysed project's autoloader - an
18+
* `instanceof ClassLoader` in AutoloadSourceLocator, say - has to name the
19+
* unprefixed Composer\Autoload\ClassLoader. php-scoper prefixes the reference in
20+
* the phar, where it then names the phar's own copy and never matches the
21+
* project's autoloader, unless a patcher in compiler/build/scoper.inc.php
22+
* strips the prefix back off in that file.
23+
*/
24+
final class ScoperComposerClassLoaderTest extends TestCase
25+
{
26+
27+
public function testClassLoaderReferencesAreNotPrefixedInPhar(): void
28+
{
29+
/** @var array{unprefixedComposerClassLoaderIn: list<string>} $namespaces */
30+
$namespaces = require __DIR__ . '/../../../compiler/build/scoper-namespaces.php';
31+
32+
$root = realpath(__DIR__ . '/../../..');
33+
if ($root === false) {
34+
self::fail('Could not resolve the repository root.');
35+
}
36+
37+
$files = [$root . '/bin/phpstan'];
38+
$finder = new Finder();
39+
$finder->followLinks();
40+
foreach ($finder->files()->name('*.php')->in($root . '/src') as $fileInfo) {
41+
$files[] = $fileInfo->getPathname();
42+
}
43+
44+
foreach ($files as $file) {
45+
$code = file_get_contents($file);
46+
if ($code === false) {
47+
self::fail(sprintf('Could not read %s', $file));
48+
}
49+
50+
if (!str_contains($code, 'Composer\Autoload\ClassLoader')) {
51+
continue;
52+
}
53+
54+
$relativePath = str_replace('\\', '/', substr($file, strlen($root) + 1));
55+
if (in_array($relativePath, $namespaces['unprefixedComposerClassLoaderIn'], true)) {
56+
continue;
57+
}
58+
59+
self::fail(sprintf(
60+
'%s refers to Composer\\Autoload\\ClassLoader. php-scoper prefixes the reference in the phar, '
61+
. "where it no longer matches the analysed project's autoloader, so the file has to be added to "
62+
. "'unprefixedComposerClassLoaderIn' in compiler/build/scoper-namespaces.php.",
63+
$relativePath,
64+
));
65+
}
66+
67+
self::expectNotToPerformAssertions();
68+
}
69+
70+
}

‎tests/PHPStan/Reflection/BetterReflection/SourceLocator/AutoloadSourceLocatorTest.php‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
namespace PHPStan\Reflection\BetterReflection\SourceLocator;
44

5+
use Composer\Autoload\ClassLoader;
6+
use Phar;
7+
use PharData;
8+
use PharException;
59
use PHPStan\BetterReflection\Reflection\ReflectionClass;
610
use PHPStan\BetterReflection\Reflector\DefaultReflector;
711
use PHPStan\Reflection\InitializerExprContext;
@@ -12,6 +16,8 @@
1216
use TestSingleFileSourceLocator\InCondition;
1317
use function array_merge;
1418
use function class_alias;
19+
use function sys_get_temp_dir;
20+
use function uniqid;
1521

1622
function testFunctionForLocator(): void // phpcs:disable
1723
{
@@ -79,6 +85,40 @@ class_alias(AFoo::class, 'A_Foo');
7985
$this->assertSame(AFoo::class, $class->getName());
8086
}
8187

88+
/**
89+
* PHPStan's own classes live in the phar, behind a Composer ClassLoader that
90+
* maps PHPStan\ to phar://.../src - and realpath() cannot resolve such a path.
91+
* A tar archive stands in for the phar: PharData writes one even with
92+
* phar.readonly on, and phar:// reads it the same way.
93+
*
94+
* @throws PharException
95+
*/
96+
public function testClassInsidePharBehindComposerClassLoader(): void
97+
{
98+
$archive = sys_get_temp_dir() . '/phpstan-autoload-source-locator-' . uniqid() . '.tar';
99+
$pharData = new PharData($archive);
100+
$pharData->addFromString('src/InPhar.php', "<?php\n\nnamespace AutoloadSourceLocatorInPhar;\n\nclass InPhar\n{\n\n}\n");
101+
unset($pharData);
102+
103+
$loader = new ClassLoader();
104+
$loader->addPsr4('AutoloadSourceLocatorInPhar\\', ['phar://' . $archive . '/src']);
105+
$loader->register(true);
106+
107+
try {
108+
$locator = new AutoloadSourceLocator(self::getContainer()->getByType(FileNodesFetcher::class), true);
109+
$reflector = new DefaultReflector($locator);
110+
$class = $reflector->reflectClass('AutoloadSourceLocatorInPhar\InPhar');
111+
$this->assertSame('InPhar', $class->getShortName());
112+
113+
$fileName = $class->getFileName();
114+
$this->assertNotNull($fileName);
115+
$this->assertStringStartsWith('phar://', $fileName);
116+
} finally {
117+
$loader->unregister();
118+
Phar::unlinkArchive($archive);
119+
}
120+
}
121+
82122
public static function getAdditionalConfigFiles(): array
83123
{
84124
return array_merge(

0 commit comments

Comments
 (0)