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
47 changes: 47 additions & 0 deletions compiler/build/scoper-namespaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php declare(strict_types=1);

return [
/**
* Namespaces php-scoper leaves alone completely - neither declarations nor
* references to them are prefixed.
*/
'excluded' => [
'PHPStan',
// the native turbo extension's classes — must match the loaded
// extension exactly, never prefixed (segment-aware matching means the
// PHPStan entry above does not cover this name)
'PHPStanTurbo',
'PHPUnit',
'PhpParser',
'Hoa',
'Symfony\Polyfill\Php80',
'Symfony\Polyfill\Php81',
'Symfony\Polyfill\Php83',
'Symfony\Polyfill\Php84',
'Symfony\Polyfill\Php85',
'Symfony\Polyfill\Mbstring',
'Symfony\Polyfill\Intl\Normalizer',
'Symfony\Polyfill\Intl\Grapheme',
],

/**
* Namespaces of classes that belong to the analysed code, referenced from
* src/ through string literals like `new ObjectType('BcMath\Number')`.
*
* php-scoper prefixes such literals, which would turn them into class names
* that do not exist in the analysed code, so a patcher in scoper.inc.php
* strips the prefix back off. They cannot simply be added to 'excluded'
* because the phar bundles polyfills declaring some of them (e.g.
* Filter\FilterFailedException from symfony/polyfill-php85) and those
* declarations do have to stay prefixed.
*/
'unprefixedClassNameStringsInSrc' => [
'BcMath',
'Dom',
'Ds',
'FFI',
'Filter',
'Foobar',
'PDO',
],
];
45 changes: 10 additions & 35 deletions compiler/build/scoper.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require_once __DIR__ . '/../vendor/autoload.php';

$namespaces = require __DIR__ . '/scoper-namespaces.php';

$stubs = [
'../../vendor/hoa/consistency/Prelude.php',
'../../vendor/composer/InstalledVersions.php',
Expand Down Expand Up @@ -213,49 +215,22 @@ function (string $filePath, string $prefix, string $content): string {

return str_replace(sprintf('%s\\PropertyHookType', $prefix), 'PropertyHookType', $content);
},
function (string $filePath, string $prefix, string $content): string {
function (string $filePath, string $prefix, string $content) use ($namespaces): string {
if (strpos($filePath, 'src/') !== 0) {
return $content;
}

return str_replace([
sprintf('\'%s\\BcMath\\', $prefix),
sprintf('\'%s\\Dom\\', $prefix),
sprintf('\'%s\\FFI\\', $prefix),
sprintf('\'%s\\Ds\\', $prefix),
], [
'\'BcMath\\',
'\'Dom\\',
'\'FFI\\',
'\'Ds\\',
], $content);
},
function (string $filePath, string $prefix, string $content): string {
if (strpos($filePath, 'src/Testing/ErrorFormatterTestCase.php') !== 0) {
return $content;
$search = [];
$replace = [];
foreach ($namespaces['unprefixedClassNameStringsInSrc'] as $namespace) {
$search[] = sprintf('\'%s\\%s\\', $prefix, $namespace);
$replace[] = sprintf('\'%s\\', $namespace);
}

return str_replace(sprintf('new Error(\'%s\\Foobar\\Buz', $prefix), 'new Error(\'Foobar\\Buz', $content);
return str_replace($search, $replace, $content);
},
],
'exclude-namespaces' => [
'PHPStan',
// the native turbo extension's classes — must match the loaded
// extension exactly, never prefixed (segment-aware matching means the
// PHPStan entry above does not cover this name)
'PHPStanTurbo',
'PHPUnit',
'PhpParser',
'Hoa',
'Symfony\Polyfill\Php80',
'Symfony\Polyfill\Php81',
'Symfony\Polyfill\Php83',
'Symfony\Polyfill\Php84',
'Symfony\Polyfill\Php85',
'Symfony\Polyfill\Mbstring',
'Symfony\Polyfill\Intl\Normalizer',
'Symfony\Polyfill\Intl\Grapheme',
],
'exclude-namespaces' => $namespaces['excluded'],
'expose-global-functions' => false,
'expose-global-classes' => false,
];
2 changes: 2 additions & 0 deletions resources/constantToFunctionParameterMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@
'FILTER_FLAG_GLOBAL_RANGE',
'FILTER_FLAG_HOSTNAME',
'FILTER_FLAG_EMAIL_UNICODE',
'FILTER_THROW_ON_FAILURE',
],
],
],
Expand Down Expand Up @@ -730,6 +731,7 @@
'FILTER_FLAG_GLOBAL_RANGE',
'FILTER_FLAG_HOSTNAME',
'FILTER_FLAG_EMAIL_UNICODE',
'FILTER_THROW_ON_FAILURE',
],
],
],
Expand Down
22 changes: 12 additions & 10 deletions src/Rules/Functions/FilterVarRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Php\FilterFunctionFlagsHelper;
use PHPStan\Type\Php\FilterFunctionReturnTypeHelper;
use function count;

/**
* @implements Rule<Node\Expr\FuncCall>
Expand All @@ -24,6 +24,7 @@ final class FilterVarRule implements Rule
public function __construct(
private ReflectionProvider $reflectionProvider,
private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper,
private FilterFunctionFlagsHelper $filterFunctionFlagsHelper,
private PhpVersion $phpVersion,
)
{
Expand All @@ -40,13 +41,12 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

if ($this->reflectionProvider->resolveFunctionName($node->name, $scope) !== 'filter_var') {
if (!$this->reflectionProvider->hasFunction($node->name, $scope)) {
return [];
}

$args = $node->getArgs();

if (count($args) < 3) {
$functionReflection = $this->reflectionProvider->getFunction($node->name, $scope);
if (!$this->filterFunctionFlagsHelper->isSupported($functionReflection)) {
return [];
}

Expand All @@ -57,12 +57,14 @@ public function processNode(Node $node, Scope $scope): array
return [];
}

$flagsType = $scope->getType($args[2]->value);
foreach ($this->filterFunctionFlagsHelper->getFlagsTypes($functionReflection, $node, $scope) as $flagsType) {
if (!$this->filterFunctionReturnTypeHelper->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType)
->and($this->filterFunctionReturnTypeHelper->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType))
->yes()
) {
continue;
}

if ($this->filterFunctionReturnTypeHelper->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType)
->and($this->filterFunctionReturnTypeHelper->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType))
->yes()
) {
return [
RuleErrorBuilder::message('Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE.')
->identifier('filterVar.nullOnFailureAndThrowOnFailure')
Expand Down
96 changes: 96 additions & 0 deletions src/Type/Php/FilterFunctionFlagsHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\ArgumentsNormalizer;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\Type;
use function array_key_exists;

/**
* Locates the values carrying filter flags in a call to one of the filter_*()
* functions, taking named arguments into account.
*/
#[AutowiredService]
final class FilterFunctionFlagsHelper
{

/** The $options parameter doubles as the flags argument. */
private const OPTIONS_PARAMETER_POSITIONS = [
'filter_var' => 2,
'filter_input' => 3,
'filter_var_array' => 1,
'filter_input_array' => 1,
];

/**
* The array variants take a filter specification per input key instead of a
* single flags argument. An integer $options is the filter id there, so it
* cannot carry any flags.
*/
private const PER_KEY_SPECIFICATION_FUNCTIONS = [
'filter_var_array' => true,
'filter_input_array' => true,
];

public function isSupported(FunctionReflection $functionReflection): bool
{
return array_key_exists($functionReflection->getName(), self::OPTIONS_PARAMETER_POSITIONS);
}

/**
* @return list<Type> types of all the values that may carry filter flags
*/
public function getFlagsTypes(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): array
{
$functionName = $functionReflection->getName();
if (!array_key_exists($functionName, self::OPTIONS_PARAMETER_POSITIONS)) {
return [];
}

$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
$scope,
$funcCall->getArgs(),
$functionReflection->getVariants(),
$functionReflection->getNamedArgumentsVariants(),
);
$normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $funcCall);
if ($normalizedFuncCall === null) {
return [];
}

$args = $normalizedFuncCall->getArgs();
$optionsPosition = self::OPTIONS_PARAMETER_POSITIONS[$functionName];
if (!isset($args[$optionsPosition])) {
return [];
}

$optionsType = $scope->getType($args[$optionsPosition]->value);
if (!array_key_exists($functionName, self::PER_KEY_SPECIFICATION_FUNCTIONS)) {
return [$optionsType];
}

if ($optionsType->isArray()->no()) {
return [];
}

$constantArrays = $optionsType->getConstantArrays();
if ($constantArrays === []) {
return [$optionsType];
}

$flagsTypes = [];
foreach ($constantArrays as $constantArray) {
foreach ($constantArray->getValueTypes() as $valueType) {
$flagsTypes[] = $valueType;
}
}

return $flagsTypes;
}

}
16 changes: 12 additions & 4 deletions src/Type/Php/FilterFunctionReturnTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@
private function getOffsetValueType(Type $inputType, Type $offsetType, ?Type $filterType, ?Type $flagsType): Type
{
$hasNullOnFailure = $this->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType);
if ($hasNullOnFailure->yes()) {
if ($this->hasThrowOnFailureFlag($flagsType)->yes()) {

Check warning on line 63 in src/Type/Php/FilterFunctionReturnTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ private function getOffsetValueType(Type $inputType, Type $offsetType, ?Type $filterType, ?Type $flagsType): Type { $hasNullOnFailure = $this->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType); - if ($this->hasThrowOnFailureFlag($flagsType)->yes()) { + if (!$this->hasThrowOnFailureFlag($flagsType)->no()) { // a missing input value throws instead of being reported through the return value $inexistentOffsetType = new NeverType(); } elseif ($hasNullOnFailure->yes()) {

Check warning on line 63 in src/Type/Php/FilterFunctionReturnTypeHelper.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ private function getOffsetValueType(Type $inputType, Type $offsetType, ?Type $filterType, ?Type $flagsType): Type { $hasNullOnFailure = $this->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType); - if ($this->hasThrowOnFailureFlag($flagsType)->yes()) { + if (!$this->hasThrowOnFailureFlag($flagsType)->no()) { // a missing input value throws instead of being reported through the return value $inexistentOffsetType = new NeverType(); } elseif ($hasNullOnFailure->yes()) {
// a missing input value throws instead of being reported through the return value
$inexistentOffsetType = new NeverType();
} elseif ($hasNullOnFailure->yes()) {
$inexistentOffsetType = new ConstantBooleanType(false);
} elseif ($hasNullOnFailure->no()) {
$inexistentOffsetType = new NullType();
Expand Down Expand Up @@ -151,9 +154,7 @@

$inputIsArray = $inputType->isArray();
$hasRequireArrayFlag = $this->hasFlag('FILTER_REQUIRE_ARRAY', $flagsType);
$hasThrowOnFailureFlag = $this->phpVersion->hasFilterThrowOnFailureConstant()
? $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType)
: TrinaryLogic::createNo();
$hasThrowOnFailureFlag = $this->hasThrowOnFailureFlag($flagsType);
if ($inputIsArray->no() && $hasRequireArrayFlag->yes()) {
if ($hasThrowOnFailureFlag->yes()) {
return new ErrorType();
Expand Down Expand Up @@ -514,6 +515,13 @@
);
}

private function hasThrowOnFailureFlag(?Type $flagsType): TrinaryLogic
{
return $this->phpVersion->hasFilterThrowOnFailureConstant()
? $this->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType)
: TrinaryLogic::createNo();
}

private function getFlagsValue(Type $exprType): Type
{
if (!$exprType->isConstantArray()->yes()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
use PHPStan\Type\Type;

#[AutowiredService]
final class FilterVarThrowTypeExtension implements DynamicFunctionThrowTypeExtension
final class FilterFunctionsThrowTypeExtension implements DynamicFunctionThrowTypeExtension
{

public function __construct(
private ReflectionProvider $reflectionProvider,
private PhpVersion $phpVersion,
private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper,
private FilterFunctionFlagsHelper $filterFunctionFlagsHelper,
)
{
}
Expand All @@ -29,7 +30,7 @@ public function isFunctionSupported(
FunctionReflection $functionReflection,
): bool
{
return $functionReflection->getName() === 'filter_var';
return $this->filterFunctionFlagsHelper->isSupported($functionReflection);
}

public function getThrowTypeFromFunctionCall(
Expand All @@ -38,21 +39,18 @@ public function getThrowTypeFromFunctionCall(
Scope $scope,
): ?Type
{
if (!isset($funcCall->getArgs()[2])) {
return null;
}

if (
!$this->phpVersion->hasFilterThrowOnFailureConstant()
|| !$this->reflectionProvider->hasConstant(new Name\FullyQualified('FILTER_THROW_ON_FAILURE'), null)
) {
return null;
}

$flagsExpr = $funcCall->getArgs()[2]->value;
$flagsType = $scope->getType($flagsExpr);
foreach ($this->filterFunctionFlagsHelper->getFlagsTypes($functionReflection, $funcCall, $scope) as $flagsType) {
if ($this->filterFunctionReturnTypeHelper->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType)->no()) {
continue;
}

if (!$this->filterFunctionReturnTypeHelper->hasFlag('FILTER_THROW_ON_FAILURE', $flagsType)->no()) {
return new ObjectType('Filter\FilterFailedException');
}

Expand Down
23 changes: 23 additions & 0 deletions tests/PHPStan/Analyser/nsrt/filter-var-php85.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,27 @@ public function more($mixed): void
assertType('array<int>', filter_var($mixed, FILTER_VALIDATE_INT, FILTER_FORCE_ARRAY|FILTER_THROW_ON_FAILURE));
assertType('array<int>', filter_var($mixed, FILTER_VALIDATE_INT, FILTER_REQUIRE_ARRAY|FILTER_THROW_ON_FAILURE));
}

public function filterInput(): void
{
try {
filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE);
$foo = 1;
} catch (\Filter\FilterFailedException $e) {
assertVariableCertainty(TrinaryLogic::createNo(), $foo);
}

// a missing input value throws instead of being returned as null
assertType('int', filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE));
assertType('int', filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, ['flags' => FILTER_THROW_ON_FAILURE]));
assertType('int|false|null', filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT));
assertType('int|false|null', filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE));
}

public function namedArguments($mixed): void
{
assertType('int', filter_var(options: FILTER_THROW_ON_FAILURE, value: $mixed, filter: FILTER_VALIDATE_INT));
assertType('int', filter_input(options: FILTER_THROW_ON_FAILURE, type: INPUT_GET, var_name: 'foo', filter: FILTER_VALIDATE_INT));
}

}
Loading
Loading