Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
Keep an overflowing integer-range bound at the int range's edge
`integerRangeMath()` cast the float bounds of the result to int. A bound
that overflowed - PHP_INT_MIN / -1 is the float 9.2233720368547758E+18 -
wrapped around (and warns since PHP 8.5), which left the range empty:
`int<PHP_INT_MIN, -1> / -1` came out as float, losing every value the
division does produce as an int.

The values past the int range are floats, so the integer part of the result
reaches no further than the int range does, and the bound stays there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBzALCg92yf3LXFxxNBzoY
  • Loading branch information
ondrejmirtes and claude committed Sep 22, 2026
commit 24de33c498a1d22083e2c0351fad7d5e0e55168b
28 changes: 26 additions & 2 deletions src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
use function str_starts_with;
use function strtolower;
use const INF;
use const PHP_INT_MAX;
use const PHP_INT_MIN;

#[AutowiredService]
Expand Down Expand Up @@ -2591,10 +2592,10 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T
}

if (is_float($min)) {
$min = (int) ceil($min);
$min = self::toIntBound(ceil($min));
}
if (is_float($max)) {
$max = (int) floor($max);
$max = self::toIntBound(floor($max));
}

// invert maximas on division with negative constants
Expand Down Expand Up @@ -2961,6 +2962,29 @@ private function resolveTypeByNameWithLateStaticBinding(Name $class, Type $class
return $classType;
}

/**
* The bound an overflowing float stands for: an int operation whose result
* leaves the int range yields a float there, so the integer part of the
* result reaches no further than the int range does. Casting the float
* instead would wrap the bound around (and warns since PHP 8.5): note that
* PHP_INT_MAX is not representable as a float, so the first float past the
* int range is (float) PHP_INT_MAX itself.
*/
private static function toIntBound(float $value): ?int
{
if (!is_finite($value)) {
return null;
}
if ($value >= (float) PHP_INT_MAX) {
return PHP_INT_MAX;
}
if ($value <= (float) PHP_INT_MIN) {
return PHP_INT_MIN;
}

return (int) $value;
}

/**
* @param mixed $value
*/
Expand Down
16 changes: 16 additions & 0 deletions tests/PHPStan/Analyser/nsrt/integer-range-division-overflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php declare(strict_types = 1);

namespace IntegerRangeDivisionOverflow;

use function PHPStan\Testing\assertType;

/**
* @param int<-9223372036854775808, -1> $negative
* @param int<-9223372036854775808, 9223372036854775807> $any
*/
function test(int $negative, int $any): void
{
// PHP_INT_MIN / -1 overflows to a float, the rest of the range stays int
assertType('int<1, 9223372036854775807>', $negative / -1);
assertType('int<-9223372036854775807, 9223372036854775807>', $any / -1);
}
25 changes: 19 additions & 6 deletions turbo-ext/src/InitializerExprTypeResolver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,23 @@ class InitializerExprTypeResolver
return fromInterval(rangeMin, rangeMax);
}

/* Mirrors toIntBound(): the bound an overflowing float stands for, since
* the values past the int range are floats. ZEND_LONG_MAX is not
* representable as a double, so the first double past the int range is
* (double) ZEND_LONG_MAX itself. */
static void toIntBound(double value, zval *out)
{
if (!std::isfinite(value)) {
ZVAL_NULL(out);
} else if (value >= (double) ZEND_LONG_MAX) {
ZVAL_LONG(out, ZEND_LONG_MAX);
} else if (value <= (double) ZEND_LONG_MIN) {
ZVAL_LONG(out, ZEND_LONG_MIN);
} else {
ZVAL_LONG(out, (zend_long) value);
}
}

/* private static: the highest possible absolute value of `$x % $divisor`,
* one less than the largest possible absolute value of the divisor; null
* when there is no such bound. A divisor reaching PHP_INT_MIN is reported
Expand Down Expand Up @@ -2954,14 +2971,10 @@ class InitializerExprTypeResolver
}

if (Z_TYPE(min) == IS_DOUBLE) {
zval ceiled;
ZVAL_DOUBLE(&ceiled, std::ceil(Z_DVAL(min)));
ZVAL_LONG(&min, zval_get_long(&ceiled));
toIntBound(std::ceil(Z_DVAL(min)), &min);
}
if (Z_TYPE(max) == IS_DOUBLE) {
zval floored;
ZVAL_DOUBLE(&floored, std::floor(Z_DVAL(max)));
ZVAL_LONG(&max, zval_get_long(&floored));
toIntBound(std::floor(Z_DVAL(max)), &max);
}

// invert maximas on division with negative constants
Expand Down
3 changes: 3 additions & 0 deletions turbo-ext/src/generated/InitializerExprTypeResolver.h
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ inline constexpr reg::Sig resolveTypeByName = { "resolveTypeByName", ZEND_ACC_PR
inline constexpr reg::Arg resolveTypeByNameWithLateStaticBinding_args[] = { reg::typed("class", 0, "PhpParser\\Node\\Name"), reg::typed("classType", 0, "PHPStan\\Type\\Type"), reg::typed("methodReflectionCandidate", 0, "PHPStan\\Reflection\\MethodReflection") };
inline constexpr reg::Arg resolveTypeByNameWithLateStaticBinding_return = reg::typed("", 0, "PHPStan\\Type\\Type");
inline constexpr reg::Sig resolveTypeByNameWithLateStaticBinding = { "resolveTypeByNameWithLateStaticBinding", ZEND_ACC_PRIVATE, 3, resolveTypeByNameWithLateStaticBinding_args, 3, &resolveTypeByNameWithLateStaticBinding_return };
inline constexpr reg::Arg toIntBound_args[] = { reg::typed("value", MAY_BE_DOUBLE) };
inline constexpr reg::Arg toIntBound_return = reg::typed("", MAY_BE_NULL | MAY_BE_LONG);
inline constexpr reg::Sig toIntBound = { "toIntBound", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, 1, toIntBound_args, 1, &toIntBound_return };
inline constexpr reg::Arg getTypeFromValue_args[] = { reg::typed("value", 0) };
inline constexpr reg::Arg getTypeFromValue_return = reg::typed("", 0, "PHPStan\\Type\\Type");
inline constexpr reg::Sig getTypeFromValue = { "getTypeFromValue", ZEND_ACC_PRIVATE, 1, getTypeFromValue_args, 1, &getTypeFromValue_return };
Expand Down