Skip to content

Commit dc636ee

Browse files
phpstan-botondrejmirtes
authored andcommitted
Detect overflowing << bounds, keep float for PHP_INT_MIN / -1, and bound % on non-integer operands
- `integerRangeMath()` computed `<<` bounds with a wrapping shift, so an overflowing shift produced an inverted range (`int<1, 2> << 62` was `*NEVER*`) or an unsound one (`int<1, 3> << 63` claimed `-9223372036854775808`, but `2 << 63` is `0`). Overflow is now detected with a shift round-trip and falls back to `int`. `>>` is arithmetic and cannot overflow, so it is left alone. - `getDivType()` removed `float` from the result whenever the modulo was `0`, which turned `(PHP_INT_MIN) / -1` into `*NEVER*` because that result is float-only. The removal is now skipped when it would leave `never`. - `getModType()` reads both operands' bounds through `toInteger()`, matching what `%` does at runtime, so `%` on non-integer numeric operands is bounded too (`float % 2.4` is `int<-1, 1>`). A divisor that truncates to `0` (`$f % 0.5`) still yields an unbounded result instead of `*NEVER*`. `bug-15242.php` also gains the remaining `%` cases probed for phpstan/phpstan#15242 (non-positive dividends, `PHP_INT_MAX` divisors, negative constant unions, `%=`), which already pass and stay as regression coverage. Probed and found already correct: `abs()` / `toAbsoluteNumber()` (already guards `PHP_INT_MIN`), `IntdivThrowTypeExtension` (already models `PHP_INT_MIN / -1`), `toBitwiseNotType()`, and the bitwise and/or/xor range helpers.
1 parent 2faa295 commit dc636ee

3 files changed

Lines changed: 101 additions & 9 deletions

File tree

‎src/Reflection/InitializerExprTypeResolver.php‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,7 +1328,12 @@ public function getDivType(Expr $left, Expr $right, callable $getTypeCallback):
13281328
if ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) {
13291329
$modType = $getTypeCallback(new BinaryOp\Mod($left, $right));
13301330
if ($modType->isInteger()->yes() && (new ConstantIntegerType(0))->isSuperTypeOf($modType)->yes()) {
1331-
return TypeCombinator::remove($result, new FloatType());
1331+
$withoutFloat = TypeCombinator::remove($result, new FloatType());
1332+
1333+
// PHP_INT_MIN / -1 divides without a remainder but still overflows to a float
1334+
if (!$withoutFloat instanceof NeverType) {
1335+
return $withoutFloat;
1336+
}
13321337
}
13331338
}
13341339

@@ -1456,14 +1461,12 @@ public function getModType(Expr $left, Expr $right, callable $getTypeCallback):
14561461
}
14571462

14581463
$maxMagnitude = null;
1459-
if ($rightType->isInteger()->yes()) {
1460-
$divisorBounds = $this->getIntegerBounds($rightType);
1461-
if ($divisorBounds !== null) {
1462-
$maxMagnitude = self::getMaxModuloMagnitude($divisorBounds[0], $divisorBounds[1]);
1463-
}
1464+
$divisorBounds = $this->getIntegerBounds($rightType->toInteger());
1465+
if ($divisorBounds !== null) {
1466+
$maxMagnitude = self::getMaxModuloMagnitude($divisorBounds[0], $divisorBounds[1]);
14641467
}
14651468

1466-
[$leftMin, $leftMax] = $this->getIntegerBounds($leftType) ?? [null, null];
1469+
[$leftMin, $leftMax] = $this->getIntegerBounds($leftType->toInteger()) ?? [null, null];
14671470
if ($leftMin === null && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes()) {
14681471
$leftMin = 0;
14691472
}
@@ -1510,7 +1513,12 @@ private static function getMaxModuloMagnitude(?int $divisorMin, ?int $divisorMax
15101513
return null;
15111514
}
15121515

1513-
return max(abs($divisorMin), abs($divisorMax)) - 1;
1516+
$magnitude = max(abs($divisorMin), abs($divisorMax));
1517+
if ($magnitude === 0) {
1518+
return null;
1519+
}
1520+
1521+
return $magnitude - 1;
15141522
}
15151523

15161524
/**
@@ -2606,6 +2614,14 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T
26062614
if ($operand->getValue() < 0) {
26072615
return new ErrorType();
26082616
}
2617+
// an overflowing shift wraps around, which breaks the monotonicity the bounds rely on
2618+
if (
2619+
($rangeMin !== null && self::shiftLeftOverflows(intval($rangeMin), $operand->getValue()))
2620+
|| ($rangeMax !== null && self::shiftLeftOverflows(intval($rangeMax), $operand->getValue()))
2621+
) {
2622+
return new IntegerType();
2623+
}
2624+
26092625
$min = $rangeMin !== null ? intval($rangeMin) << $operand->getValue() : null;
26102626
$max = $rangeMax !== null ? intval($rangeMax) << $operand->getValue() : null;
26112627
} elseif ($node instanceof Expr\BinaryOp\ShiftRight) {
@@ -2631,6 +2647,15 @@ private function integerRangeMath(Type $range, BinaryOp $node, Type $operand): T
26312647
return IntegerRangeType::fromInterval($min, $max);
26322648
}
26332649

2650+
/**
2651+
* A left shift that does not fit into an integer wraps around instead of turning
2652+
* into a float, so the shifted value no longer preserves the ordering of its operand.
2653+
*/
2654+
private static function shiftLeftOverflows(int $value, int $shift): bool
2655+
{
2656+
return ($value << $shift) >> $shift !== $value;
2657+
}
2658+
26342659
/**
26352660
* @param callable(Expr): Type $getTypeCallback
26362661
*/

‎tests/PHPStan/Analyser/nsrt/binary.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ public function doFoo(array $generalArray)
310310
assertType('1.2', 1.2 ** 1);
311311
assertType('(float|int)', $integer ** $integer);
312312
assertType('1', 3.2 % 2);
313-
assertType('int', $float3 %= 2.4);
313+
assertType('int<-1, 1>', $float3 %= 2.4);
314314
assertType('float', $float4 **= 2.4);
315315
assertType('float', $float5 /= 2.4);
316316
assertType('float', $float6 *= 2);

‎tests/PHPStan/Analyser/nsrt/bug-15242.php‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,70 @@ function phpIntMinDivisor(int $i): void
2727
assertType('int<0, 10>', $i % (-9223372036854775807 - 1));
2828
assertType('int<0, 10>', $i % PHP_INT_MIN);
2929
}
30+
31+
class Foo
32+
{
33+
34+
/**
35+
* @param int<0, 30> $nonNegative
36+
* @param int<min, 0> $nonPositive
37+
*/
38+
public function doFoo(int $nonNegative, int $nonPositive): void
39+
{
40+
assertType('int<-8, 0>', $nonPositive % -9);
41+
assertType('int<-8, 0>', $nonPositive % 9);
42+
assertType('0', $nonNegative % -1);
43+
}
44+
45+
/**
46+
* @param int<0, 10> $i
47+
*/
48+
public function doBar(int $i): void
49+
{
50+
assertType('int<0, 10>', $i % PHP_INT_MAX);
51+
assertType('int<0, 10>', $i % -9223372036854775807);
52+
}
53+
54+
/**
55+
* @param int<0, 30> $nonNegative
56+
* @param int<min, 0> $nonPositive
57+
* @param -9|-3 $negativeConstants
58+
* @param int<min, -5> $negativeUnbounded
59+
*/
60+
public function doModVariants(int $nonNegative, int $nonPositive, int $negativeConstants, int $negativeUnbounded): void
61+
{
62+
assertType('int<0, 8>', $nonNegative % $negativeConstants);
63+
assertType('int<-8, 0>', $nonPositive % $negativeConstants);
64+
assertType('int<0, 30>', $nonNegative % $negativeUnbounded);
65+
66+
$compound = $nonNegative;
67+
$compound %= -9;
68+
assertType('int<0, 8>', $compound);
69+
}
70+
71+
/**
72+
* @param int<0, 30> $nonNegative
73+
* @param int<min, 5> $rMin
74+
*/
75+
public function doDiv(int $nonNegative, int $rMin, int $i): void
76+
{
77+
assertType('int<-30, 0>', $nonNegative / -1);
78+
assertType('int<-5, max>', $rMin / -1);
79+
assertType('int', $i / -1);
80+
assertType('9.223372036854776E+18', (-9223372036854775807 - 1) / -1);
81+
}
82+
83+
/**
84+
* @param int<1, 2> $small
85+
* @param int<1, 3> $small3
86+
* @param int<0, 30> $nonNegative
87+
*/
88+
public function doShiftLeft(int $small, int $small3, int $nonNegative): void
89+
{
90+
assertType('int<4, 8>', $small << 2);
91+
assertType('int', $small << 62);
92+
assertType('int', $small3 << 63);
93+
assertType('int', $nonNegative << 60);
94+
}
95+
96+
}

0 commit comments

Comments
 (0)