From 0e3a12de27ee2debc94a70d5e13c1321ca258347 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 2 Aug 2025 16:08:23 +0200 Subject: [PATCH 1/3] Reproducer --- .github/workflows/e2e-tests.yml | 3 +++ e2e/bug-10483/bug-10483.php | 7 +++++++ e2e/bug-10483/phpstan.dist.neon | 4 ++++ 3 files changed, 14 insertions(+) create mode 100644 e2e/bug-10483/bug-10483.php create mode 100644 e2e/bug-10483/phpstan.dist.neon diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9f9e1376c6..af71d5fbf0 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -282,6 +282,9 @@ jobs: cd e2e/ignore-error-extension composer install ../../bin/phpstan + - script: | + cd e2e/bug-10483 + ../../bin/phpstan steps: - name: "Checkout" diff --git a/e2e/bug-10483/bug-10483.php b/e2e/bug-10483/bug-10483.php new file mode 100644 index 0000000000..24495bd074 --- /dev/null +++ b/e2e/bug-10483/bug-10483.php @@ -0,0 +1,7 @@ + Date: Sat, 2 Aug 2025 16:55:14 +0200 Subject: [PATCH 2/3] Fix --- .../Php/FilterFunctionReturnTypeHelper.php | 107 ++++++++++++------ 1 file changed, 70 insertions(+), 37 deletions(-) diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index 44e3fb55b9..d9e616a330 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -6,7 +6,6 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\ReflectionProvider; -use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; @@ -32,7 +31,6 @@ use function is_int; use function octdec; use function preg_match; -use function sprintf; #[AutowiredService] final class FilterFunctionReturnTypeHelper @@ -58,7 +56,7 @@ public function __construct(private ReflectionProvider $reflectionProvider, priv public function getOffsetValueType(Type $inputType, Type $offsetType, ?Type $filterType, ?Type $flagsType): Type { - $inexistentOffsetType = $this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsType) + $inexistentOffsetType = $this->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType) ? new ConstantBooleanType(false) : new NullType(); @@ -107,6 +105,9 @@ public function getType(Type $inputType, ?Type $filterType, ?Type $flagsType): T if ($filterType === null) { $filterValue = $this->getConstant('FILTER_DEFAULT'); + if (null === $filterValue) { + return $mixedType; + } } else { if (!$filterType instanceof ConstantIntegerType) { return $mixedType; @@ -121,17 +122,17 @@ public function getType(Type $inputType, ?Type $filterType, ?Type $flagsType): T $hasOptions = $this->hasOptions($flagsType); $options = $hasOptions->yes() ? $this->getOptions($flagsType, $filterValue) : []; - $defaultType = $options['default'] ?? ($this->hasFlag($this->getConstant('FILTER_NULL_ON_FAILURE'), $flagsType) + $defaultType = $options['default'] ?? ($this->hasFlag('FILTER_NULL_ON_FAILURE', $flagsType) ? new NullType() : new ConstantBooleanType(false)); $inputIsArray = $inputType->isArray(); - $hasRequireArrayFlag = $this->hasFlag($this->getConstant('FILTER_REQUIRE_ARRAY'), $flagsType); + $hasRequireArrayFlag = $this->hasFlag('FILTER_REQUIRE_ARRAY', $flagsType); if ($inputIsArray->no() && $hasRequireArrayFlag) { return $defaultType; } - $hasForceArrayFlag = $this->hasFlag($this->getConstant('FILTER_FORCE_ARRAY'), $flagsType); + $hasForceArrayFlag = $this->hasFlag('FILTER_FORCE_ARRAY', $flagsType); if ($inputIsArray->yes() && ($hasRequireArrayFlag || $hasForceArrayFlag)) { $inputArrayKeyType = $inputType->getIterableKeyType(); $inputType = $inputType->getIterableValueType(); @@ -187,32 +188,47 @@ private function getFilterTypeMap(): array $stringType = new StringType(); $nonFalsyStringType = TypeCombinator::intersect($stringType, new AccessoryNonFalsyStringType()); - $this->filterTypeMap = [ - $this->getConstant('FILTER_UNSAFE_RAW') => $stringType, - $this->getConstant('FILTER_SANITIZE_EMAIL') => $stringType, - $this->getConstant('FILTER_SANITIZE_ENCODED') => $stringType, - $this->getConstant('FILTER_SANITIZE_NUMBER_FLOAT') => $stringType, - $this->getConstant('FILTER_SANITIZE_NUMBER_INT') => $stringType, - $this->getConstant('FILTER_SANITIZE_SPECIAL_CHARS') => $stringType, - $this->getConstant('FILTER_SANITIZE_STRING') => $stringType, - $this->getConstant('FILTER_SANITIZE_URL') => $stringType, - $this->getConstant('FILTER_VALIDATE_BOOLEAN') => $booleanType, - $this->getConstant('FILTER_VALIDATE_DOMAIN') => $stringType, - $this->getConstant('FILTER_VALIDATE_EMAIL') => $nonFalsyStringType, - $this->getConstant('FILTER_VALIDATE_FLOAT') => $floatType, - $this->getConstant('FILTER_VALIDATE_INT') => $intType, - $this->getConstant('FILTER_VALIDATE_IP') => $nonFalsyStringType, - $this->getConstant('FILTER_VALIDATE_MAC') => $nonFalsyStringType, - $this->getConstant('FILTER_VALIDATE_REGEXP') => $stringType, - $this->getConstant('FILTER_VALIDATE_URL') => $nonFalsyStringType, + $map = [ + 'FILTER_UNSAFE_RAW' => $stringType, + 'FILTER_SANITIZE_EMAIL' => $stringType, + 'FILTER_SANITIZE_ENCODED' => $stringType, + 'FILTER_SANITIZE_NUMBER_FLOAT' => $stringType, + 'FILTER_SANITIZE_NUMBER_INT' => $stringType, + 'FILTER_SANITIZE_SPECIAL_CHARS' => $stringType, + 'FILTER_SANITIZE_STRING' => $stringType, + 'FILTER_SANITIZE_URL' => $stringType, + 'FILTER_VALIDATE_BOOLEAN' => $booleanType, + 'FILTER_VALIDATE_DOMAIN' => $stringType, + 'FILTER_VALIDATE_EMAIL' => $nonFalsyStringType, + 'FILTER_VALIDATE_FLOAT' => $floatType, + 'FILTER_VALIDATE_INT' => $intType, + 'FILTER_VALIDATE_IP' => $nonFalsyStringType, + 'FILTER_VALIDATE_MAC' => $nonFalsyStringType, + 'FILTER_VALIDATE_REGEXP' => $stringType, + 'FILTER_VALIDATE_URL' => $nonFalsyStringType, ]; + $this->filterTypeMap = []; + foreach ($map as $filter => $type) { + $constant = $this->getConstant($filter); + if ($constant === null) { + continue; + } + $this->filterTypeMap[$constant] = $type; + } + if ($this->reflectionProvider->hasConstant(new Node\Name('FILTER_SANITIZE_MAGIC_QUOTES'), null)) { - $this->filterTypeMap[$this->getConstant('FILTER_SANITIZE_MAGIC_QUOTES')] = $stringType; + $sanitizeMagicQuote = $this->getConstant('FILTER_SANITIZE_MAGIC_QUOTES'); + if ($sanitizeMagicQuote !== null) { + $this->filterTypeMap[$sanitizeMagicQuote] = $stringType; + } } if ($this->reflectionProvider->hasConstant(new Node\Name('FILTER_SANITIZE_ADD_SLASHES'), null)) { - $this->filterTypeMap[$this->getConstant('FILTER_SANITIZE_ADD_SLASHES')] = $stringType; + $sanitizeAddSlashes = $this->getConstant('FILTER_SANITIZE_ADD_SLASHES'); + if ($sanitizeAddSlashes !== null) { + $this->filterTypeMap[$sanitizeAddSlashes] = $stringType; + } } return $this->filterTypeMap; @@ -227,24 +243,33 @@ private function getFilterTypeOptions(): array return $this->filterTypeOptions; } - $this->filterTypeOptions = [ - $this->getConstant('FILTER_VALIDATE_INT') => ['min_range', 'max_range'], + $map = [ + 'FILTER_VALIDATE_INT' => ['min_range', 'max_range'], // PHPStan does not yet support FloatRangeType - // $this->getConstant('FILTER_VALIDATE_FLOAT') => ['min_range', 'max_range'], + // 'FILTER_VALIDATE_FLOAT' => ['min_range', 'max_range'], ]; + $this->filterTypeOptions = []; + foreach ($map as $filter => $type) { + $constant = $this->getConstant($filter); + if ($constant === null) { + continue; + } + $this->filterTypeOptions[$constant] = $type; + } + return $this->filterTypeOptions; } /** * @param non-empty-string $constantName */ - private function getConstant(string $constantName): int + private function getConstant(string $constantName): ?int { $constant = $this->reflectionProvider->getConstant(new Node\Name($constantName), null); $valueType = $constant->getValueType(); if (!$valueType instanceof ConstantIntegerType) { - throw new ShouldNotHappenException(sprintf('Constant %s does not have integer type.', $constantName)); + return null; } return $valueType->getValue(); @@ -301,8 +326,8 @@ private function determineExactType(Type $in, int $filterValue, Type $defaultTyp if ($in instanceof ConstantStringType) { $value = $in->getValue(); - $allowOctal = $this->hasFlag($this->getConstant('FILTER_FLAG_ALLOW_OCTAL'), $flagsType); - $allowHex = $this->hasFlag($this->getConstant('FILTER_FLAG_ALLOW_HEX'), $flagsType); + $allowOctal = $this->hasFlag('FILTER_FLAG_ALLOW_OCTAL', $flagsType); + $allowHex = $this->hasFlag('FILTER_FLAG_ALLOW_HEX', $flagsType); if ($allowOctal && preg_match('/\A0[oO][0-7]+\z/', $value) === 1) { $octalValue = octdec($value); @@ -411,8 +436,16 @@ private function getOptions(Type $flagsType, int $filterValue): array return $options; } - private function hasFlag(int $flag, ?Type $flagsType): bool + /** + * @param non-empty-string $flagName + */ + private function hasFlag(string $flagName, ?Type $flagsType): bool { + $flag = $this->getConstant($flagName); + if (null === $flag) { + return false; + } + if ($flagsType === null) { return false; } @@ -441,9 +474,9 @@ private function canStringBeSanitized(int $filterValue, ?Type $flagsType): bool // FILTER_DEFAULT will not sanitize, unless it has FILTER_FLAG_STRIP_LOW, // FILTER_FLAG_STRIP_HIGH, or FILTER_FLAG_STRIP_BACKTICK if ($filterValue === $this->getConstant('FILTER_DEFAULT')) { - return $this->hasFlag($this->getConstant('FILTER_FLAG_STRIP_LOW'), $flagsType) - || $this->hasFlag($this->getConstant('FILTER_FLAG_STRIP_HIGH'), $flagsType) - || $this->hasFlag($this->getConstant('FILTER_FLAG_STRIP_BACKTICK'), $flagsType); + return $this->hasFlag('FILTER_FLAG_STRIP_LOW', $flagsType) + || $this->hasFlag('FILTER_FLAG_STRIP_HIGH', $flagsType) + || $this->hasFlag('FILTER_FLAG_STRIP_BACKTICK', $flagsType); } return true; From 925ba2c5ab5be5a046cee8722505722050054d78 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 2 Aug 2025 16:58:45 +0200 Subject: [PATCH 3/3] Fix --- e2e/bug-10483/bug-10483.php | 3 +++ src/Type/Php/FilterFunctionReturnTypeHelper.php | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/bug-10483/bug-10483.php b/e2e/bug-10483/bug-10483.php index 24495bd074..df3867ef78 100644 --- a/e2e/bug-10483/bug-10483.php +++ b/e2e/bug-10483/bug-10483.php @@ -2,6 +2,9 @@ define("FILTER_VALIDATE_FLOAT",false); +/** @return mixed */ +function doFoo() { return null; } + $mixed = doFoo(); if (filter_var($mixed, FILTER_VALIDATE_BOOLEAN)) { } diff --git a/src/Type/Php/FilterFunctionReturnTypeHelper.php b/src/Type/Php/FilterFunctionReturnTypeHelper.php index d9e616a330..cb2e93acdf 100644 --- a/src/Type/Php/FilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/FilterFunctionReturnTypeHelper.php @@ -105,7 +105,7 @@ public function getType(Type $inputType, ?Type $filterType, ?Type $flagsType): T if ($filterType === null) { $filterValue = $this->getConstant('FILTER_DEFAULT'); - if (null === $filterValue) { + if ($filterValue === null) { return $mixedType; } } else { @@ -442,7 +442,7 @@ private function getOptions(Type $flagsType, int $filterValue): array private function hasFlag(string $flagName, ?Type $flagsType): bool { $flag = $this->getConstant($flagName); - if (null === $flag) { + if ($flag === null) { return false; }