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
100 changes: 100 additions & 0 deletions src/Parser/IncludeResolutionChangedVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php declare(strict_types = 1);

namespace PHPStan\Parser;

use Override;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PHPStan\DependencyInjection\AutowiredService;
use function in_array;
use function strtolower;

/**
* Where an `include`/`require` path ends up depends on the working directory, on the `include_path`
* setting and on the registered stream wrappers, all of which the analysed file can change at
* runtime. This marks every `Include_` node that such a call appears before in the same file, so
* that RequireFileExistsRule can treat its path as one that does not resolve to a known place
* instead of assuming the state of PHPStan's own process.
*/
#[AutowiredService]
final class IncludeResolutionChangedVisitor extends NodeVisitorAbstract
{

/** Holds `true` when a call earlier in the file changed where a path resolves. */
public const ATTRIBUTE_NAME = 'includeResolutionChanged';

private const FUNCTION_NAMES = [
'chdir',
'set_include_path',
'stream_wrapper_register',
'ini_set',
'ini_alter',
];

private bool $changed = false;

#[Override]
public function beforeTraverse(array $nodes): ?array
{
$this->changed = false;

return null;
}

#[Override]
public function enterNode(Node $node): ?Node
{
if ($node instanceof Node\Expr\Include_) {
if ($this->changed) {
$node->setAttribute(self::ATTRIBUTE_NAME, true);
}

return null;
}

if (
!$node instanceof Node\Expr\FuncCall
|| !$node->name instanceof Node\Name
|| $node->isFirstClassCallable()
) {
return null;
}

$functionName = $node->name->toLowerString();
if (!in_array($functionName, self::FUNCTION_NAMES, true)) {
return null;
}

if (
($functionName === 'ini_set' || $functionName === 'ini_alter')
&& !$this->couldSetIncludePath($node)
) {
return null;
}

$this->changed = true;

return null;
}

/**
* An `ini_set()` of an unrelated option such as `memory_limit` leaves include resolution alone.
* An option name that is not a literal string could be `include_path` just as well as anything
* else.
*/
private function couldSetIncludePath(Node\Expr\FuncCall $node): bool
{
$args = $node->getArgs();
if ($args === []) {
return false;
}

$optionName = $args[0]->value;
if (!$optionName instanceof Node\Scalar\String_) {
return true;
}

return strtolower($optionName->value) === 'include_path';
}

}
7 changes: 7 additions & 0 deletions src/Rules/Keywords/RequireFileExistsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use PHPStan\File\FileHelper;
use PHPStan\File\IncludedFilePathResolver;
use PHPStan\Node\Printer\ExprPrinter;
use PHPStan\Parser\IncludeResolutionChangedVisitor;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
Expand Down Expand Up @@ -64,6 +65,12 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

if ($node->getAttribute(IncludeResolutionChangedVisitor::ATTRIBUTE_NAME) === true) {
// A call earlier in the file moved the working directory, changed the include path or
// registered a stream wrapper, so the path no longer names a place PHPStan can look at.
return [];
}

$errors = [];
$usedMagicDirFallback = false;
$paths = $this->resolveFilePaths($node->expr, $scope, $usedMagicDirFallback);
Expand Down
64 changes: 64 additions & 0 deletions tests/PHPStan/Rules/Keywords/RequireFileExistsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ public function testPathWithAnUnregisteredStreamWrapper(): void
]);
}

public function testStreamWrapperRegisteredByTheFileItself(): void
{
// Only the include before the stream_wrapper_register() call is reported: after it, the
// file resolves paths through wrappers PHPStan does not have.
$this->analyse([__DIR__ . '/data/require-file-stream-wrapper-registered.php'], [
[
'Path in require_once() "modulea://sites/default/modulea.php" is not a file or it does not exist.',
5,
],
]);
}

public function testBasicCase(): void
{
$this->analyse([__DIR__ . '/data/require-file-simple-case.php'], [
Expand Down Expand Up @@ -204,6 +216,58 @@ public function testBug15015(): void
]);
}

public function testChdir(): void
{
// The include after the chdir() is not reported: the path is relative to a working
// directory the file moved, so it no longer names a place PHPStan can look at. Neither is
// the absolute one - the whole file is given up on, which is the point of keeping this
// simple.
$this->analyse([__DIR__ . '/data/require-file-chdir.php'], [
[
'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
5,
],
]);
}

public function testSetIncludePath(): void
{
$this->analyse([__DIR__ . '/data/require-file-set-include-path.php'], [
[
'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
5,
],
]);
}

public function testIniSetIncludePath(): void
{
// ini_set('memory_limit', ...) and ini_alter('precision', ...) leave include resolution
// alone, ini_set('INCLUDE_PATH', ...) does not - the option name is case-insensitive.
$this->analyse([__DIR__ . '/data/require-file-include-path.php'], [
[
'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
8,
],
]);
}

public function testIniSetWithUnknownOption(): void
{
// The option could be include_path just as well as anything else.
$this->analyse([__DIR__ . '/data/require-file-ini-set-unknown.php'], [
[
'Path in require_once() "a-file-that-does-not-exist.php" is not a file or it does not exist.',
5,
],
]);
}

public function testBug15260(): void
{
$this->analyse([__DIR__ . '/data/bug-15260/sub/bug-15260.php'], []);
}

public function testInFileExists(): void
{
$this->analyse([__DIR__ . '/data/include-in-file-exists.php'], []);
Expand Down
3 changes: 3 additions & 0 deletions tests/PHPStan/Rules/Keywords/data/bug-15260/config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?php declare(strict_types = 1);

// Included by bug-15260/sub/bug-15260.php after it chdir()s into this directory.
6 changes: 6 additions & 0 deletions tests/PHPStan/Rules/Keywords/data/bug-15260/sub/bug-15260.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php declare(strict_types = 1);

namespace Bug15260;

chdir('..');
require_once('config.php');
10 changes: 10 additions & 0 deletions tests/PHPStan/Rules/Keywords/data/require-file-chdir.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);

namespace RequireFileChdir;

require_once 'a-file-that-does-not-exist.php';

chdir('..');

require_once 'a-file-that-does-not-exist.php';
require_once __DIR__ . '/a-file-that-does-not-exist.php';
12 changes: 12 additions & 0 deletions tests/PHPStan/Rules/Keywords/data/require-file-include-path.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php declare(strict_types = 1);

namespace RequireFileIncludePath;

ini_set('memory_limit', '1G');
ini_alter('precision', '10');

require_once 'a-file-that-does-not-exist.php';

ini_set('INCLUDE_PATH', __DIR__);

require_once 'a-file-that-does-not-exist.php';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php declare(strict_types = 1);

namespace RequireFileIniSetUnknown;

require_once 'a-file-that-does-not-exist.php';

ini_set($_SERVER['OPTION'], $_SERVER['VALUE']);

require_once 'a-file-that-does-not-exist.php';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php declare(strict_types = 1);

namespace RequireFileSetIncludePath;

require_once 'a-file-that-does-not-exist.php';

\set_include_path(__DIR__);

require_once 'a-file-that-does-not-exist.php';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);

namespace RequireFileStreamWrapperRegistered;

require_once 'modulea://sites/default/modulea.php';

\stream_wrapper_register('modulea', \stdClass::class);

require_once 'modulea://sites/default/modulea.php';
require_once 'moduleb://sites/default/moduleb.php';
Loading