diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3e50294c94..55c4014bf4 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 8608ac867e..ebdfbf1fd7 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 0000000000..308d33fa95 --- /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 0000000000..5d1ff714a9 --- /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); + } +}