From a4d61dd3f4beeed9bb1c897d31e5d3308aaa4f5f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:52:51 +0000 Subject: [PATCH] Un-prefix `Filter\` and `PDO\` class name strings in the phar, detect `FILTER_THROW_ON_FAILURE` in all `filter_*()` * Move php-scoper's excluded namespaces and the list of namespaces whose class name strings in src/ have to be un-prefixed again into the shared compiler/build/scoper-namespaces.php, and add `Filter` (the reported bug: `new ObjectType('Filter\FilterFailedException')` was reported as `_PHPStan_\Filter\FilterFailedException`) and `PDO` (the same bug in `PDOConnectReturnTypeExtension`). Replaces the one-off `src/Testing/ErrorFormatterTestCase.php` patcher with the generic one. * Add ScoperClassNameStringsTest, which scans every class name string literal in src/ and fails when its namespace is neither excluded from prefixing nor un-prefixed by the patcher. * Add `FILTER_THROW_ON_FAILURE` to the allowed constants of `filter_var()` and `filter_input()`'s `$options` parameter - passing it was reported as `argument.invalidConstant`. * Extract `FilterFunctionFlagsHelper`, which resolves the flag-carrying values of a `filter_*()` call through `ArgumentsNormalizer`, so named arguments work, and which understands the per-input-key filter specification of `filter_var_array()`/`filter_input_array()`. * Rename `FilterVarThrowTypeExtension` to `FilterFunctionsThrowTypeExtension` and let it cover `filter_input()`, `filter_var_array()` and `filter_input_array()`, which throw `Filter\FilterFailedException` too. * `FilterVarRule` now checks `filter_input()`, `filter_var_array()` and `filter_input_array()` as well, per input key for the array variants, and no longer reads the `$options` argument positionally. * `filter_input()` with `FILTER_THROW_ON_FAILURE` no longer returns `null` for a missing input value - that case throws. --- compiler/build/scoper-namespaces.php | 47 ++++++++ compiler/build/scoper.inc.php | 45 ++------ resources/constantToFunctionParameterMap.php | 2 + src/Rules/Functions/FilterVarRule.php | 22 ++-- src/Type/Php/FilterFunctionFlagsHelper.php | 96 ++++++++++++++++ .../Php/FilterFunctionReturnTypeHelper.php | 16 ++- ... => FilterFunctionsThrowTypeExtension.php} | 16 ++- .../Analyser/nsrt/filter-var-php85.php | 23 ++++ .../Build/ScoperClassNameStringsTest.php | 108 ++++++++++++++++++ .../data/filter-var-throw-on-failure.php | 43 +++++++ .../Rules/Functions/FilterVarRuleTest.php | 14 +++ .../data/constant-parameter-check.php | 5 + .../data/filter-functions-null-and-throw.php | 21 ++++ 13 files changed, 400 insertions(+), 58 deletions(-) create mode 100644 compiler/build/scoper-namespaces.php create mode 100644 src/Type/Php/FilterFunctionFlagsHelper.php rename src/Type/Php/{FilterVarThrowTypeExtension.php => FilterFunctionsThrowTypeExtension.php} (73%) create mode 100644 tests/PHPStan/Build/ScoperClassNameStringsTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/filter-functions-null-and-throw.php diff --git a/compiler/build/scoper-namespaces.php b/compiler/build/scoper-namespaces.php new file mode 100644 index 00000000000..eba18486e7e --- /dev/null +++ b/compiler/build/scoper-namespaces.php @@ -0,0 +1,47 @@ + [ + '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', + ], +]; diff --git a/compiler/build/scoper.inc.php b/compiler/build/scoper.inc.php index f6e29aa24e7..6407e29f8c4 100644 --- a/compiler/build/scoper.inc.php +++ b/compiler/build/scoper.inc.php @@ -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', @@ -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, ]; diff --git a/resources/constantToFunctionParameterMap.php b/resources/constantToFunctionParameterMap.php index 1bdc3a7b8d4..63c83ba8a2d 100644 --- a/resources/constantToFunctionParameterMap.php +++ b/resources/constantToFunctionParameterMap.php @@ -657,6 +657,7 @@ 'FILTER_FLAG_GLOBAL_RANGE', 'FILTER_FLAG_HOSTNAME', 'FILTER_FLAG_EMAIL_UNICODE', + 'FILTER_THROW_ON_FAILURE', ], ], ], @@ -730,6 +731,7 @@ 'FILTER_FLAG_GLOBAL_RANGE', 'FILTER_FLAG_HOSTNAME', 'FILTER_FLAG_EMAIL_UNICODE', + 'FILTER_THROW_ON_FAILURE', ], ], ], diff --git a/src/Rules/Functions/FilterVarRule.php b/src/Rules/Functions/FilterVarRule.php index d8f80ed90f6..147dfd0cf86 100644 --- a/src/Rules/Functions/FilterVarRule.php +++ b/src/Rules/Functions/FilterVarRule.php @@ -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 @@ -24,6 +24,7 @@ final class FilterVarRule implements Rule public function __construct( private ReflectionProvider $reflectionProvider, private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper, + private FilterFunctionFlagsHelper $filterFunctionFlagsHelper, private PhpVersion $phpVersion, ) { @@ -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 []; } @@ -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') diff --git a/src/Type/Php/FilterFunctionFlagsHelper.php b/src/Type/Php/FilterFunctionFlagsHelper.php new file mode 100644 index 00000000000..1aa9efc6c32 --- /dev/null +++ b/src/Type/Php/FilterFunctionFlagsHelper.php @@ -0,0 +1,96 @@ + 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 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; + } + +} diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index 0d097ec62c1..484738fa9fc 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -60,7 +60,10 @@ public function __construct(private ReflectionProvider $reflectionProvider, priv 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()) { + // 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(); @@ -151,9 +154,7 @@ public function getType(Type $inputType, ?Type $filterType, ?Type $flagsType): T $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(); @@ -514,6 +515,13 @@ public function hasFlag(string $flagName, ?Type $flagsType): TrinaryLogic ); } + 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()) { diff --git a/src/Type/Php/FilterVarThrowTypeExtension.php b/src/Type/Php/FilterFunctionsThrowTypeExtension.php similarity index 73% rename from src/Type/Php/FilterVarThrowTypeExtension.php rename to src/Type/Php/FilterFunctionsThrowTypeExtension.php index 0ace100db83..cb1649ae103 100644 --- a/src/Type/Php/FilterVarThrowTypeExtension.php +++ b/src/Type/Php/FilterFunctionsThrowTypeExtension.php @@ -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, ) { } @@ -29,7 +30,7 @@ public function isFunctionSupported( FunctionReflection $functionReflection, ): bool { - return $functionReflection->getName() === 'filter_var'; + return $this->filterFunctionFlagsHelper->isSupported($functionReflection); } public function getThrowTypeFromFunctionCall( @@ -38,10 +39,6 @@ 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) @@ -49,10 +46,11 @@ public function getThrowTypeFromFunctionCall( 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'); } diff --git a/tests/PHPStan/Analyser/nsrt/filter-var-php85.php b/tests/PHPStan/Analyser/nsrt/filter-var-php85.php index 71a548e4f9e..d59d791ae70 100644 --- a/tests/PHPStan/Analyser/nsrt/filter-var-php85.php +++ b/tests/PHPStan/Analyser/nsrt/filter-var-php85.php @@ -29,4 +29,27 @@ public function more($mixed): void assertType('array', filter_var($mixed, FILTER_VALIDATE_INT, FILTER_FORCE_ARRAY|FILTER_THROW_ON_FAILURE)); assertType('array', 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)); + } + } diff --git a/tests/PHPStan/Build/ScoperClassNameStringsTest.php b/tests/PHPStan/Build/ScoperClassNameStringsTest.php new file mode 100644 index 00000000000..f4e27858232 --- /dev/null +++ b/tests/PHPStan/Build/ScoperClassNameStringsTest.php @@ -0,0 +1,108 @@ +, unprefixedClassNameStringsInSrc: list} $namespaces */ + $namespaces = require __DIR__ . '/../../../compiler/build/scoper-namespaces.php'; + + $finder = new Finder(); + $finder->followLinks(); + foreach ($finder->files()->name('*.php')->in(__DIR__ . '/../../../src') as $fileInfo) { + $file = $fileInfo->getPathname(); + $code = file_get_contents($file); + if ($code === false) { + self::fail(sprintf('Could not read %s', $file)); + } + + foreach (token_get_all($code) as $token) { + if (!is_array($token) || $token[0] !== T_CONSTANT_ENCAPSED_STRING) { + continue; + } + + $className = self::parseStringLiteral($token[1]); + if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)+$/', $className) !== 1) { + continue; + } + + if ( + self::belongsToNamespace($className, $namespaces['excluded']) + || self::belongsToNamespace($className, $namespaces['unprefixedClassNameStringsInSrc']) + ) { + continue; + } + + self::fail(sprintf( + "%s on line %d contains the class name string '%s'. php-scoper prefixes such strings in the phar, " + . "so its root namespace has to be added to 'unprefixedClassNameStringsInSrc' in compiler/build/scoper-namespaces.php. " + . 'Namespaces currently handled: %s.', + $file, + $token[2], + $className, + implode(', ', $namespaces['unprefixedClassNameStringsInSrc']), + )); + } + } + + self::expectNotToPerformAssertions(); + } + + private static function parseStringLiteral(string $token): string + { + $contents = substr($token, 1, -1); + if ($token[0] === '\'') { + return str_replace(['\\\\', '\\\''], ['\\', '\''], $contents); + } + + // double-quoted strings with any escape sequence besides \\ cannot be class names + $contents = str_replace('\\\\', "\0", $contents); + if (strpos($contents, '\\') !== false) { + return ''; + } + + return str_replace("\0", '\\', $contents); + } + + /** + * @param list $namespaces + */ + private static function belongsToNamespace(string $className, array $namespaces): bool + { + foreach ($namespaces as $namespace) { + // namespaces are case-insensitive in PHP and so is php-scoper's matching + if (str_starts_with(strtolower($className), strtolower($namespace) . '\\')) { + return true; + } + } + + return false; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/filter-var-throw-on-failure.php b/tests/PHPStan/Rules/Exceptions/data/filter-var-throw-on-failure.php index e34f5d5f01b..4285e478bf5 100644 --- a/tests/PHPStan/Rules/Exceptions/data/filter-var-throw-on-failure.php +++ b/tests/PHPStan/Rules/Exceptions/data/filter-var-throw-on-failure.php @@ -27,3 +27,46 @@ function validateInt(mixed $value): void } catch (\Filter\FilterFailedException) { } } + +function validateWithNamedArguments(mixed $value): void +{ + try { + filter_var(options: FILTER_THROW_ON_FAILURE, value: $value, filter: FILTER_VALIDATE_INT); + } catch (\Filter\FilterFailedException) { + } +} + +function validateInput(): void +{ + try { + filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE); + } catch (\Filter\FilterFailedException) { + } +} + +function validateInputWithNamedArguments(): void +{ + try { + filter_input(options: FILTER_THROW_ON_FAILURE, type: INPUT_GET, var_name: 'foo', filter: FILTER_VALIDATE_INT); + } catch (\Filter\FilterFailedException) { + } +} + +/** + * @param array $data + */ +function validateVarArray(array $data): void +{ + try { + filter_var_array($data, ['foo' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_THROW_ON_FAILURE]]); + } catch (\Filter\FilterFailedException) { + } +} + +function validateInputArray(): void +{ + try { + filter_input_array(INPUT_GET, ['foo' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_THROW_ON_FAILURE]]); + } catch (\Filter\FilterFailedException) { + } +} diff --git a/tests/PHPStan/Rules/Functions/FilterVarRuleTest.php b/tests/PHPStan/Rules/Functions/FilterVarRuleTest.php index f6af5002b91..91edc0ed7f4 100644 --- a/tests/PHPStan/Rules/Functions/FilterVarRuleTest.php +++ b/tests/PHPStan/Rules/Functions/FilterVarRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPStan\Type\Php\FilterFunctionFlagsHelper; use PHPStan\Type\Php\FilterFunctionReturnTypeHelper; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -17,6 +18,7 @@ protected function getRule(): Rule return new FilterVarRule( self::createReflectionProvider(), self::getContainer()->getByType(FilterFunctionReturnTypeHelper::class), + self::getContainer()->getByType(FilterFunctionFlagsHelper::class), self::getContainer()->getByType(PhpVersion::class), ); } @@ -31,6 +33,18 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.5.0')] + public function testFilterFunctions(): void + { + $this->analyse([__DIR__ . '/data/filter-functions-null-and-throw.php'], [ + ['Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE.', 7], + ['Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE.', 8], + ['Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE.', 9], + ['Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE.', 10], + ['Cannot use both FILTER_NULL_ON_FAILURE and FILTER_THROW_ON_FAILURE.', 11], + ]); + } + #[RequiresPhp('>= 8.2.0')] public function testRuleWithGlobalRange(): void { diff --git a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php index a4d49c584df..d1289721b02 100644 --- a/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php +++ b/tests/PHPStan/Rules/Functions/data/constant-parameter-check.php @@ -120,3 +120,8 @@ public function hashPassword(string $password): string // preg_replace_callback_array: correct constant in $flags preg_replace_callback_array(['/foo/' => fn ($m) => $m[0]], 'subject', -1, $count, PREG_UNMATCHED_AS_NULL); + +// filter_var/filter_input: FILTER_THROW_ON_FAILURE is a valid flag (PHP 8.5) +filter_var('foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE); +filter_var('foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE | FILTER_FLAG_ALLOW_HEX); +filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE); diff --git a/tests/PHPStan/Rules/Functions/data/filter-functions-null-and-throw.php b/tests/PHPStan/Rules/Functions/data/filter-functions-null-and-throw.php new file mode 100644 index 00000000000..a19845c4e23 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/filter-functions-null-and-throw.php @@ -0,0 +1,21 @@ += 8.5 + +declare(strict_types = 1); + +namespace FilterFunctionsNullAndThrow; + +filter_var(value: 'foo@bar.test', options: FILTER_THROW_ON_FAILURE|FILTER_NULL_ON_FAILURE, filter: FILTER_VALIDATE_EMAIL); +filter_input(INPUT_GET, 'foo', FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE|FILTER_NULL_ON_FAILURE); +filter_input(type: INPUT_GET, options: FILTER_THROW_ON_FAILURE|FILTER_NULL_ON_FAILURE, var_name: 'foo', filter: FILTER_VALIDATE_INT); +filter_var_array([], ['foo' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_THROW_ON_FAILURE|FILTER_NULL_ON_FAILURE]]); +filter_input_array(INPUT_GET, ['foo' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_THROW_ON_FAILURE|FILTER_NULL_ON_FAILURE]]); + +// the flags belong to different per-key specifications +filter_var_array([], [ + 'foo' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_NULL_ON_FAILURE], + 'bar' => ['filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_THROW_ON_FAILURE], +]); + +// an integer $options is the filter id, it does not carry any flags +filter_var_array([], FILTER_VALIDATE_INT); +filter_input_array(INPUT_GET, FILTER_VALIDATE_INT);