Skip to content
Open
1 change: 1 addition & 0 deletions build/collision-detector.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"../tests/PHPStan/Parser/data/cleaning-property-hooks-after.php",
"../tests/PHPStan/Rules/Functions/data/duplicate-function.php",
"../tests/PHPStan/Rules/Classes/data/duplicate-class.php",
"../tests/PHPStan/Rules/data/file-reference.php",
"../tests/PHPStan/Rules/Names/data/multiple-namespaces.php",
"../tests/PHPStan/Rules/Names/data/no-namespace.php",
"../tests/notAutoloaded",
Expand Down
1 change: 1 addition & 0 deletions conf/bleedingEdge.neon
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ parameters:
newOnNonObject: true
unnecessaryNullCoalesce: true
finiteTypesInHaystack: true
checkFileReferences: true
1 change: 1 addition & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ parameters:
newOnNonObject: false
unnecessaryNullCoalesce: false
finiteTypesInHaystack: false
checkFileReferences: false
fileExtensions:
- php
checkAdvancedIsset: false
Expand Down
1 change: 1 addition & 0 deletions conf/parametersSchema.neon
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ parametersSchema:
newOnNonObject: bool()
unnecessaryNullCoalesce: bool()
finiteTypesInHaystack: bool()
checkFileReferences: bool()
])
fileExtensions: listOf(string())
checkAdvancedIsset: bool()
Expand Down
100 changes: 100 additions & 0 deletions src/File/FileExistenceChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php declare(strict_types = 1);

namespace PHPStan\File;

use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use function array_merge;
use function explode;
use function get_include_path;
use function is_dir;
use function is_file;
use const PATH_SEPARATOR;

/**
* Resolves a possibly-relative path the same way PHP would at runtime and checks whether it exists.
*
* We cannot use stream_resolve_include_path() because it works based on the calling script.
* This mirrors its behavior but for an arbitrary script directory. The priority order is:
* 1. The base directories (e.g. from an attribute argument).
* 2. The current working directory.
* 3. The include path.
* 4. The directory of the script that is being analysed.
*/
#[AutowiredService]
final class FileExistenceChecker
{

/**
* Functions whose whole purpose is to test whether a path exists, so passing
* them a non-existent path is expected and must not be reported.
*
* Relevant once phpstorm-stubs annotate these with #[JetBrains\PhpStorm\FileReference]:
* @see https://youtrack.jetbrains.com/issue/WI-85516/FileReference-and-builtin-functions-like-isfile
*/
public const FILE_EXISTENCE_FUNCTIONS = [
'file_exists',
'is_file',
'is_readable',
'is_writable',
'is_writeable',
'is_executable',
];

public function __construct(
#[AutowiredParameter]
private string $currentWorkingDirectory,
)
{
}

public function fileExists(string $path, string $scriptDirectory): bool
{
return $this->exists($path, $scriptDirectory, [], false);
}

/**
* Like fileExists(), but a directory at the resolved path also counts as existing.
*
* @param list<string> $baseDirectories
*/
public function pathExists(string $path, string $scriptDirectory, array $baseDirectories): bool
{
return $this->exists($path, $scriptDirectory, $baseDirectories, true);
}

/**
* @param list<string> $baseDirectories
*/
private function exists(string $path, string $scriptDirectory, array $baseDirectories, bool $allowDirectory): bool
{
$scriptHelper = new FileHelper($scriptDirectory);
$resolvedBaseDirectories = [];
foreach ($baseDirectories as $baseDirectory) {
// a relative base directory is resolved against the directory of the analysed script
$resolvedBaseDirectories[] = $scriptHelper->absolutizePath($baseDirectory);
}

$directories = array_merge(
$resolvedBaseDirectories,
[$this->currentWorkingDirectory],
explode(PATH_SEPARATOR, get_include_path()),
[$scriptDirectory],
);

foreach ($directories as $directory) {
$absolutePath = (new FileHelper($directory))->absolutizePath($path);

if (is_file($absolutePath)) {
return true;
}

if ($allowDirectory && is_dir($absolutePath)) {
return true;
}
}

return false;
}

}
116 changes: 116 additions & 0 deletions src/Rules/FunctionCallParametersCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\File\FileExistenceChecker;
use PHPStan\Reflection\ConstantReflection;
use PHPStan\Reflection\ExtendedParameterReflection;
use PHPStan\Reflection\ParameterReflection;
Expand Down Expand Up @@ -37,18 +38,22 @@
use function array_last;
use function array_merge;
use function count;
use function dirname;
use function implode;
use function in_array;
use function is_int;
use function is_string;
use function lcfirst;
use function max;
use function sprintf;
use function strtolower;

#[AutowiredService]
final class FunctionCallParametersCheck
{

private const FILE_REFERENCE_ATTRIBUTE = 'JetBrains\PhpStorm\FileReference';

public function __construct(
private RuleLevelHelper $ruleLevelHelper,
private NullsafeCheck $nullsafeCheck,
Expand All @@ -63,6 +68,9 @@ public function __construct(
private bool $checkExtraArguments,
#[AutowiredParameter]
private bool $checkMissingTypehints,
private ?FileExistenceChecker $fileExistenceChecker = null,
#[AutowiredParameter(ref: '%featureToggles.checkFileReferences%')]
private bool $checkFileReferences = false,
)
{
}
Expand Down Expand Up @@ -324,6 +332,10 @@ public function check(
$errors[] = $error;
}

foreach ($this->checkFileReferences($argumentsWithParameters, $scope, $funcCall) as $error) {
$errors[] = $error;
}

if (!$this->checkArgumentTypes && !$this->checkArgumentsPassedByReference) {
return $errors;
}
Expand Down Expand Up @@ -782,6 +794,110 @@ private function processArguments(
return [$errors, $newArguments];
}

/**
* Validates constant-string arguments passed to parameters annotated with
* #[JetBrains\PhpStorm\FileReference], reusing the include/require file resolution.
*
* @param array<int, array{Expr, Type|null, bool, (string|null), int, (ParameterReflection|null), (ParameterReflection|null)}> $argumentsWithParameters
* @return list<IdentifierRuleError>
*/
private function checkFileReferences(array $argumentsWithParameters, Scope $scope, Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall): array
{
if (!$this->checkFileReferences || $this->fileExistenceChecker === null) {
return [];
}

if ($this->isFileExistenceTestingCall($funcCall, $scope)) {
// e.g. is_file()/file_exists() legitimately receive paths that may not exist
return [];
}

$errors = [];
$scriptDirectory = dirname($scope->getFile());
foreach ($argumentsWithParameters as $i => [$argumentValue, $argumentValueType, $unpack, $argumentName, $argumentLine, $parameter]) {
if ($unpack) {
continue;
}
if (!$parameter instanceof ExtendedParameterReflection) {
continue;
}

$baseDirectories = $this->getFileReferenceBaseDirectories($parameter);
if ($baseDirectories === null) {
continue;
}

$argumentValueType ??= $scope->getType($argumentValue);
foreach ($argumentValueType->getConstantStrings() as $constantString) {
$path = $constantString->getValue();
if ($path === '') {
continue;
}
if ($this->fileExistenceChecker->pathExists($path, $scriptDirectory, $baseDirectories)) {
continue;
}

$errors[] = RuleErrorBuilder::message(sprintf(
'Path "%s" passed to %s does not exist.',
$path,
lcfirst($this->describeParameter($parameter, $argumentName ?? $i + 1)),
))
->identifier('argument.fileReference')
->line($argumentLine)
->build();
}
}

return $errors;
}

private function isFileExistenceTestingCall(Node\Expr\FuncCall|Node\Expr\MethodCall|Node\Expr\StaticCall|Node\Expr\New_ $funcCall, Scope $scope): bool
{
if (!$funcCall instanceof Node\Expr\FuncCall || !$funcCall->name instanceof Node\Name) {
return false;
}

if (!$this->reflectionProvider->hasFunction($funcCall->name, $scope)) {
return false;
}

$functionName = strtolower($this->reflectionProvider->getFunction($funcCall->name, $scope)->getName());

return in_array($functionName, FileExistenceChecker::FILE_EXISTENCE_FUNCTIONS, true);
}

/**
* @return list<string>|null Base directories from the attribute's $basePath, or null when the parameter is not a file reference
*/
private function getFileReferenceBaseDirectories(ExtendedParameterReflection $parameter): ?array
{
$isFileReference = false;
$baseDirectories = [];
foreach ($parameter->getAttributes() as $attribute) {
if ($attribute->getName() !== self::FILE_REFERENCE_ATTRIBUTE) {
continue;
}
$isFileReference = true;

$basePathType = $attribute->getArgumentTypes()['basePath'] ?? null;
if ($basePathType === null) {
continue;
}
foreach ($basePathType->getConstantStrings() as $basePath) {
if ($basePath->getValue() === '') {
continue;
}
$baseDirectories[] = $basePath->getValue();
}
}

if (!$isFileReference) {
return null;
}

return $baseDirectories;
}

private function describeParameter(ParameterReflection $parameter, int|string|null $positionOrNamed): string
{
$parts = [];
Expand Down
54 changes: 4 additions & 50 deletions src/Rules/Keywords/RequireFileExistsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,16 @@
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\File\FileExistenceChecker;
use PHPStan\File\FileHelper;
use PHPStan\Node\Printer\ExprPrinter;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Constant\ConstantStringType;
use function array_merge;
use function dirname;
use function explode;
use function get_include_path;
use function is_file;
use function sprintf;
use const PATH_SEPARATOR;

/**
* @implements Rule<Include_>
Expand All @@ -33,26 +29,12 @@
final class RequireFileExistsRule implements Rule
{

/**
* Functions that, when they return true, guarantee the path exists on the
* filesystem, so guarding a require/include with them suppresses the error.
*/
private const FILE_EXISTENCE_FUNCTIONS = [
'file_exists',
'is_file',
'is_readable',
'is_writable',
'is_writeable',
'is_executable',
];

public function __construct(
#[AutowiredParameter]
private string $currentWorkingDirectory,
private ExprPrinter $exprPrinter,
#[AutowiredParameter(ref: '%featureToggles.magicDirInInclude%')]
private bool $checkMagicDirInInclude,
private FileHelper $fileHelper,
private FileExistenceChecker $fileExistenceChecker,
)
{
}
Expand Down Expand Up @@ -91,37 +73,9 @@ public function processNode(Node $node, Scope $scope): array
return $errors;
}

/**
* We cannot use `stream_resolve_include_path` as it works based on the calling script.
* This method simulates the behavior of `stream_resolve_include_path` but for the given scope.
* The priority order is the following:
* 1. The current working directory.
* 2. The include path.
* 3. The path of the script that is being executed.
*/
private function doesFileExist(string $path, Scope $scope): bool
{
$directories = array_merge(
[$this->currentWorkingDirectory],
explode(PATH_SEPARATOR, get_include_path()),
[dirname($scope->getFile())],
);

foreach ($directories as $directory) {
if ($this->doesFileExistForDirectory($path, $directory)) {
return true;
}
}

return false;
}

private function doesFileExistForDirectory(string $path, string $workingDirectory): bool
{
$fileHelper = new FileHelper($workingDirectory);
$absolutePath = $fileHelper->absolutizePath($path);

return is_file($absolutePath);
return $this->fileExistenceChecker->fileExists($path, dirname($scope->getFile()));
}

private function getErrorMessage(Include_ $node, string $filePath): IdentifierRuleError
Expand Down Expand Up @@ -203,7 +157,7 @@ private function resolveFilePaths(Expr $expr, Scope $scope, bool &$magicDirFallb

private function isInFileExists(Include_ $node, Scope $scope): bool
{
foreach (self::FILE_EXISTENCE_FUNCTIONS as $funcName) {
foreach (FileExistenceChecker::FILE_EXISTENCE_FUNCTIONS as $funcName) {
$expr = new FuncCall(new FullyQualified($funcName), [
new Arg($node->expr),
]);
Expand Down
Loading
Loading