Skip to content

Commit 4d699ef

Browse files
ondrejmirtesclaude
andauthored
Do not resolve include paths through a stream wrapper that is not registered (#6397)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d72de75 commit 4d699ef

4 files changed

Lines changed: 103 additions & 0 deletions

File tree

‎src/File/IncludedFilePathResolver.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
use function dirname;
1111
use function explode;
1212
use function get_include_path;
13+
use function in_array;
14+
use function preg_match;
15+
use function stream_get_wrappers;
16+
use function strtolower;
1317
use const PATH_SEPARATOR;
1418

1519
/**
@@ -34,6 +38,10 @@ public function __construct(
3438
*/
3539
public function resolve(string $path, Scope $scope): array
3640
{
41+
if ($this->hasUnavailableStreamWrapper($path)) {
42+
return [];
43+
}
44+
3745
$directories = array_merge(
3846
[$this->currentWorkingDirectory],
3947
explode(PATH_SEPARATOR, get_include_path()),
@@ -53,6 +61,23 @@ public function resolve(string $path, Scope $scope): array
5361
return array_values($candidatePaths);
5462
}
5563

64+
/**
65+
* A path like `vfs://sites/default/x.php` names a stream wrapper rather than a place on the
66+
* filesystem. When that wrapper is not registered in the PHPStan process - vfsStream registers
67+
* its own from a test's setUp(), which never runs here - PHP cannot stat the path at all: every
68+
* is_file() on it raises "Unable to find the wrapper". Such a path has no candidates, and it
69+
* cannot come to have any, so nothing downstream should keep stat'ing it - the result cache
70+
* would otherwise record it as a missing file dependency and warn on every run.
71+
*/
72+
private function hasUnavailableStreamWrapper(string $path): bool
73+
{
74+
if (preg_match('~^([a-z0-9+\-.]+)://~i', $path, $matches) !== 1) {
75+
return false;
76+
}
77+
78+
return !in_array(strtolower($matches[1]), stream_get_wrappers(), true);
79+
}
80+
5681
/**
5782
* Both `__DIR__` and the "calling script's own directory" fallback of a relative include are
5883
* resolved at compile time, so inside a trait they point at the file the trait is declared in - not
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\File;
4+
5+
use PHPStan\Analyser\Scope;
6+
use PHPStan\Testing\PHPStanTestCase;
7+
use function array_map;
8+
use function str_replace;
9+
10+
final class IncludedFilePathResolverTest extends PHPStanTestCase
11+
{
12+
13+
private function createResolver(): IncludedFilePathResolver
14+
{
15+
return new IncludedFilePathResolver(__DIR__, self::getContainer()->getByType(FileHelper::class));
16+
}
17+
18+
private function createScope(): Scope
19+
{
20+
$scope = $this->createMock(Scope::class);
21+
$scope->method('isInTrait')->willReturn(false);
22+
$scope->method('getFile')->willReturn(__DIR__ . '/test/lorem.php');
23+
24+
return $scope;
25+
}
26+
27+
public function testPathWithAnUnregisteredStreamWrapperHasNoCandidates(): void
28+
{
29+
// is_file() on it raises "Unable to find the wrapper" instead of answering, and the path
30+
// cannot become readable later either - vfsStream registers its wrapper from a test's
31+
// setUp(), which never runs inside PHPStan.
32+
$this->assertSame([], $this->createResolver()->resolve('vfs://drupal/sites/default/x.php', $this->createScope()));
33+
}
34+
35+
public function testPathWithARegisteredStreamWrapperIsKept(): void
36+
{
37+
$this->assertSame(['php://memory'], $this->createResolver()->resolve('php://memory', $this->createScope()));
38+
}
39+
40+
public function testRelativePathIsResolvedAgainstEveryDirectory(): void
41+
{
42+
// Compared with forward slashes so the expectations hold on Windows too, where
43+
// absolutizePath() joins with a backslash.
44+
$paths = array_map(
45+
static fn (string $path): string => str_replace('\\', '/', $path),
46+
$this->createResolver()->resolve('lorem.php', $this->createScope()),
47+
);
48+
$directory = str_replace('\\', '/', __DIR__);
49+
50+
$this->assertContains($directory . '/lorem.php', $paths);
51+
$this->assertContains($directory . '/test/lorem.php', $paths);
52+
}
53+
54+
}

‎tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ public static function getAdditionalConfigFiles(): array
4141
];
4242
}
4343

44+
public function testPathWithAnUnregisteredStreamWrapper(): void
45+
{
46+
// PHPStan cannot see through a wrapper that is not registered, so the path is still
47+
// reported - it just must not be stat'd to get there, see IncludedFilePathResolverTest.
48+
$this->analyse([__DIR__ . '/data/require-file-stream-wrapper.php'], [
49+
[
50+
'Path in include_once() "vfs://drupal/sites/default/modules/module_a/module_a.post_update.php" is not a file or it does not exist.',
51+
7,
52+
],
53+
[
54+
'Path in require() "not-a-registered-wrapper://somewhere/else.php" is not a file or it does not exist.',
55+
8,
56+
],
57+
]);
58+
}
59+
4460
public function testBasicCase(): void
4561
{
4662
$this->analyse([__DIR__ . '/data/require-file-simple-case.php'], [
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace RequireFileStreamWrapper;
4+
5+
// vfsStream registers the "vfs" wrapper from a test's setUp(), so it is not registered while
6+
// PHPStan runs and is_file() on the path cannot answer - it raises a warning instead.
7+
include_once 'vfs://drupal/sites/default/modules/module_a/module_a.post_update.php';
8+
require 'not-a-registered-wrapper://somewhere/else.php';

0 commit comments

Comments
 (0)