From b6cce48fa2fb0c802195028431ad6884cc452c78 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:13:55 +0000 Subject: [PATCH] Narrow `filter_var` first argument type via `FunctionTypeSpecifyingExtension` when validation filter passes - Add `FilterVarTypeSpecifyingExtension` implementing `FunctionTypeSpecifyingExtension` to narrow the first argument of `filter_var()` when the function result is truthy (validation passed) - Add `getInputNarrowingType()` to `FilterFunctionReturnTypeHelper` to determine the appropriate narrowing type based on the filter constant and flags - Add delegation in `TypeSpecifier::resolveNormalizedIdentical` for `filter_var(...) === false` pattern to propagate narrowing from the comparison to the function argument - Covers FILTER_VALIDATE_EMAIL, FILTER_VALIDATE_IP, FILTER_VALIDATE_URL, FILTER_VALIDATE_MAC (all narrow to non-falsy-string) - Correctly does NOT narrow for FILTER_VALIDATE_DOMAIN, FILTER_VALIDATE_REGEXP (return type is just string), FILTER_VALIDATE_INT/FLOAT/BOOLEAN (return type is not string), FILTER_DEFAULT, and sanitize filters --- src/Analyser/TypeSpecifier.php | 24 +++ .../Php/FilterFunctionReturnTypeHelper.php | 31 ++++ .../Php/FilterVarTypeSpecifyingExtension.php | 56 +++++++ tests/PHPStan/Analyser/nsrt/bug-14486.php | 138 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 src/Type/Php/FilterVarTypeSpecifyingExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14486.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3e50294c94b..55c4014bf40 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2942,6 +2942,30 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope )->setRootExpr($expr); } + // filter_var($a, FILTER_VALIDATE_*) === false + if ( + !$context->null() + && $unwrappedLeftExpr instanceof FuncCall + && $unwrappedLeftExpr->name instanceof Name + && strtolower($unwrappedLeftExpr->name->toString()) === 'filter_var' + && $rightType->isFalse()->yes() + && count($unwrappedLeftExpr->getArgs()) >= 1 + ) { + $funcCallTypes = $this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + + if ($context->false()) { + $argSpecifiedTypes = $this->specifyTypesInCondition( + $scope, + $leftExpr, + TypeSpecifierContext::createTrue(), + )->setRootExpr($expr); + + return $funcCallTypes->unionWith($argSpecifiedTypes); + } + + return $funcCallTypes; + } + // get_class($a) === 'Foo' if ( $context->true() diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index 8608ac867ef..ebdfbf1fd79 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -532,6 +532,37 @@ private function getFlagsValue(Type $exprType): Type ); } + public function getInputNarrowingType(?Type $filterType, ?Type $flagsType): ?Type + { + if ($filterType === null) { + return null; + } + + if (!$filterType instanceof ConstantIntegerType) { + return null; + } + + $filterValue = $filterType->getValue(); + + if (($filterValue & self::VALIDATION_FILTER_BITMASK) === 0) { + return null; + } + + $returnType = $this->getType(new StringType(), $filterType, $flagsType); + $successType = TypeCombinator::remove($returnType, new ConstantBooleanType(false)); + $successType = TypeCombinator::remove($successType, new NullType()); + + if (!$successType->isString()->yes() || !$successType->isNonEmptyString()->yes()) { + return null; + } + + if ($successType->isNonFalsyString()->yes()) { + return new AccessoryNonFalsyStringType(); + } + + return new AccessoryNonEmptyStringType(); + } + private function canStringBeSanitized(int $filterValue, ?Type $flagsType): TrinaryLogic { // If it is a validation filter, the string will not be changed diff --git a/src/Type/Php/FilterVarTypeSpecifyingExtension.php b/src/Type/Php/FilterVarTypeSpecifyingExtension.php new file mode 100644 index 00000000000..308d33fa95b --- /dev/null +++ b/src/Type/Php/FilterVarTypeSpecifyingExtension.php @@ -0,0 +1,56 @@ +getName()) === 'filter_var' + && $context->truthy(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + if (count($args) < 2) { + return new SpecifiedTypes(); + } + + $filterType = $scope->getType($args[1]->value); + $flagsType = isset($args[2]) ? $scope->getType($args[2]->value) : null; + + $narrowingType = $this->filterFunctionReturnTypeHelper->getInputNarrowingType($filterType, $flagsType); + if ($narrowingType === null) { + return new SpecifiedTypes(); + } + + return $this->typeSpecifier->create($args[0]->value, $narrowingType, $context, $scope); + } + + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void + { + $this->typeSpecifier = $typeSpecifier; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14486.php b/tests/PHPStan/Analyser/nsrt/bug-14486.php new file mode 100644 index 00000000000..5d1ff714a93 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14486.php @@ -0,0 +1,138 @@ + ['regexp' => '//']])) { + throw new \InvalidArgumentException('Does not match'); + } + + // FILTER_VALIDATE_REGEXP could match empty string, no narrowing + assertType('string', $str); +} + +function noNarrowingForDefault(string $str): void +{ + if (false === filter_var($str, FILTER_DEFAULT)) { + throw new \InvalidArgumentException('Invalid'); + } + + // FILTER_DEFAULT is not a validation filter, no narrowing + assertType('string', $str); +} + +function noNarrowingWithoutFilter(string $str): void +{ + if (filter_var($str)) { + // No second argument, uses FILTER_DEFAULT, no narrowing + assertType('string', $str); + } +} + +function noNarrowingInFalsyBranch(string $email): void +{ + if (false === filter_var($email, FILTER_VALIDATE_EMAIL)) { + // Filter failed, but $email could still be any string + assertType('string', $email); + } +} + +function filterWithNullOnFailure(string $email): void +{ + $result = filter_var($email, FILTER_VALIDATE_EMAIL, FILTER_NULL_ON_FAILURE); + assertType('non-falsy-string|null', $result); + + if ($result !== null) { + assertType('non-falsy-string', $result); + } +} + +function noNarrowingForValidateInt(string $str): void +{ + if (filter_var($str, FILTER_VALIDATE_INT) !== false) { + // FILTER_VALIDATE_INT returns int, not string - no string narrowing + assertType('string', $str); + } +} + +function noNarrowingForSanitize(string $str): void +{ + if (filter_var($str, FILTER_SANITIZE_EMAIL)) { + // FILTER_SANITIZE_EMAIL is a sanitize filter, not validation + assertType('string', $str); + } +}