Skip to content

Commit f91b631

Browse files
zonuexeondrejmirtes
authored andcommitted
Compare float bounds against PHP_INT_MAX + 1.0 in IntegerRangeType
The previous commit made createAllSmallerThan() and createAllGreaterThanOrEqualTo() treat a float bound `>= PHP_INT_MAX` as past the int range. That holds on 64-bit builds, where comparing with PHP_INT_MAX converts it to float and rounds it up to 2**63, but on 32-bit builds PHP_INT_MAX is exact as a float: createAllGreaterThanOrEqualTo( 2147483647.0) would return never although the int PHP_INT_MAX satisfies it, and createAllSmallerThan(2147483647.0) would return int instead of int<min, 2147483646>. Decide on the ceil() that is about to be cast instead, compared against PHP_INT_MAX + 1.0, the first float above PHP_INT_MAX, which is exact on both widths (2**63 and 2**31). On 64-bit builds every float at or above 2**52 is integral, so the result there is the same as with `>=`. This is the same code as proposed for 2.3.x in #6546. testCreateFromFloat's literals are 64-bit int bounds, so it is skipped on 32-bit hosts; testFloatBounds covers both widths.
1 parent 9f6a488 commit f91b631

4 files changed

Lines changed: 137 additions & 47 deletions

File tree

‎src/Type/IntegerRangeType.php‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
class IntegerRangeType extends IntegerType implements CompoundType
3636
{
3737

38+
/** The first float above PHP_INT_MAX: 2^63 on 64-bit builds, 2^31 on 32-bit ones, exact on both. */
39+
private const FLOAT_ABOVE_INT_MAX = PHP_INT_MAX + 1.0;
40+
3841
private function __construct(private ?int $min, private ?int $max)
3942
{
4043
parent::__construct();
@@ -92,17 +95,19 @@ public static function createAllSmallerThan($value): Type
9295
return self::fromInterval(null, $value, -1);
9396
}
9497

95-
// a float never holds PHP_INT_MAX itself, so the first float past the
96-
// int range is (float) PHP_INT_MAX: every int is smaller than that
97-
if ($value >= PHP_INT_MAX) {
98+
// decided on the ceil() about to be cast: comparing $value with PHP_INT_MAX
99+
// converts PHP_INT_MAX to float, which rounds it up to 2^63 on 64-bit builds
100+
// but keeps it exact on 32-bit ones, where 2147483647.0 is still in range
101+
$ceil = ceil($value);
102+
if ($ceil >= self::FLOAT_ABOVE_INT_MAX) {
98103
return new IntegerType();
99104
}
100105

101106
if ($value <= PHP_INT_MIN) {
102107
return new NeverType();
103108
}
104109

105-
return self::fromInterval(null, (int) ceil($value), -1);
110+
return self::fromInterval(null, (int) $ceil, -1);
106111
}
107112

108113
/**
@@ -164,13 +169,13 @@ public static function createAllGreaterThanOrEqualTo($value): Type
164169
return new IntegerType();
165170
}
166171

167-
// (float) PHP_INT_MAX is already past the int range, so no int
168-
// reaches it
169-
if ($value >= PHP_INT_MAX) {
172+
// decided on the ceil() about to be cast, like createAllSmallerThan()
173+
$ceil = ceil($value);
174+
if ($ceil >= self::FLOAT_ABOVE_INT_MAX) {
170175
return new NeverType();
171176
}
172177

173-
return self::fromInterval((int) ceil($value), null);
178+
return self::fromInterval((int) $ceil, null);
174179
}
175180

176181
public function getMin(): ?int

‎tests/PHPStan/Type/IntegerRangeTypeTest.php‎

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,16 @@
44

55
use PHPStan\Testing\PHPStanTestCase;
66
use PHPUnit\Framework\Attributes\DataProvider;
7+
use RuntimeException;
8+
use function restore_error_handler;
9+
use function set_error_handler;
710
use const PHP_INT_MAX;
11+
use const PHP_INT_MIN;
12+
use const PHP_INT_SIZE;
813

914
class IntegerRangeTypeTest extends PHPStanTestCase
1015
{
1116

12-
public function testCreateFromFloatPastTheIntRange(): void
13-
{
14-
// PHP_INT_MAX is not representable as a float: (float) PHP_INT_MAX is
15-
// the first float past the int range
16-
$pastMax = (float) PHP_INT_MAX;
17-
18-
$this->assertSame('int', IntegerRangeType::createAllSmallerThan($pastMax)->describe(VerbosityLevel::precise()));
19-
$this->assertSame('int', IntegerRangeType::createAllSmallerThanOrEqualTo($pastMax)->describe(VerbosityLevel::precise()));
20-
$this->assertSame('*NEVER*', IntegerRangeType::createAllGreaterThan($pastMax)->describe(VerbosityLevel::precise()));
21-
$this->assertSame('*NEVER*', IntegerRangeType::createAllGreaterThanOrEqualTo($pastMax)->describe(VerbosityLevel::precise()));
22-
}
23-
2417
public static function dataCreateFromFloat(): iterable
2518
{
2619
// 2**63 is PHP_INT_MAX + 1, the smallest float greater than every int
@@ -69,6 +62,10 @@ public function testCreateFromFloat(
6962
string $expectedGreaterThanOrEqualTo,
7063
): void
7164
{
65+
if (PHP_INT_SIZE !== 8) {
66+
$this->markTestSkipped('The bounds are those of a 64-bit int.');
67+
}
68+
7269
$this->assertSame([
7370
'createAllSmallerThan' => $expectedSmallerThan,
7471
'createAllSmallerThanOrEqualTo' => $expectedSmallerThanOrEqualTo,
@@ -82,4 +79,58 @@ public function testCreateFromFloat(
8279
]);
8380
}
8481

82+
public static function dataFloatBounds(): iterable
83+
{
84+
$smallerThan = static fn (float $value): Type => IntegerRangeType::createAllSmallerThan($value);
85+
$smallerThanOrEqualTo = static fn (float $value): Type => IntegerRangeType::createAllSmallerThanOrEqualTo($value);
86+
$greaterThan = static fn (float $value): Type => IntegerRangeType::createAllGreaterThan($value);
87+
$greaterThanOrEqualTo = static fn (float $value): Type => IntegerRangeType::createAllGreaterThanOrEqualTo($value);
88+
89+
// PHP_INT_MAX + 1.0 is the first float above PHP_INT_MAX on any int width, and
90+
// (float) PHP_INT_MIN is exact on any int width
91+
$aboveMax = PHP_INT_MAX + 1.0;
92+
yield [$smallerThan, $aboveMax, 'int'];
93+
yield [$smallerThanOrEqualTo, $aboveMax, 'int'];
94+
yield [$greaterThan, $aboveMax, '*NEVER*'];
95+
yield [$greaterThanOrEqualTo, $aboveMax, '*NEVER*'];
96+
yield [$smallerThan, (float) PHP_INT_MIN, '*NEVER*'];
97+
yield [$smallerThanOrEqualTo, (float) PHP_INT_MIN, (string) PHP_INT_MIN];
98+
yield [$greaterThan, (float) PHP_INT_MIN, 'int<' . (PHP_INT_MIN + 1) . ', max>'];
99+
yield [$greaterThanOrEqualTo, (float) PHP_INT_MIN, 'int'];
100+
101+
// (float) PHP_INT_MAX is 2^63 on 64-bit builds (above every int) and exact on 32-bit
102+
// ones; so is PHP_INT_MAX - 0.5, which is PHP_INT_MAX - 1 + 0.5 on 32-bit builds
103+
$maxIsExact = PHP_INT_SIZE < 8;
104+
yield [$smallerThan, (float) PHP_INT_MAX, $maxIsExact ? 'int<min, ' . (PHP_INT_MAX - 1) . '>' : 'int'];
105+
yield [$smallerThanOrEqualTo, (float) PHP_INT_MAX, 'int'];
106+
yield [$greaterThan, (float) PHP_INT_MAX, '*NEVER*'];
107+
yield [$greaterThanOrEqualTo, (float) PHP_INT_MAX, $maxIsExact ? (string) PHP_INT_MAX : '*NEVER*'];
108+
yield [$smallerThan, PHP_INT_MAX - 0.5, $maxIsExact ? 'int<min, ' . (PHP_INT_MAX - 1) . '>' : 'int'];
109+
yield [$greaterThanOrEqualTo, PHP_INT_MAX - 0.5, $maxIsExact ? (string) PHP_INT_MAX : '*NEVER*'];
110+
111+
// between PHP_INT_MAX and the next int on 32-bit builds, 2^63 on 64-bit ones: its
112+
// ceil() is past the int range either way
113+
yield [$smallerThan, PHP_INT_MAX + 0.5, 'int'];
114+
yield [$greaterThanOrEqualTo, PHP_INT_MAX + 0.5, '*NEVER*'];
115+
116+
yield [$smallerThan, 2.5, 'int<min, 2>'];
117+
yield [$greaterThan, 2.5, 'int<3, max>'];
118+
}
119+
120+
/**
121+
* @param callable(float): Type $factory
122+
*/
123+
#[DataProvider('dataFloatBounds')]
124+
public function testFloatBounds(callable $factory, float $value, string $expected): void
125+
{
126+
set_error_handler(static function (int $errno, string $errstr): bool {
127+
throw new RuntimeException($errstr);
128+
});
129+
try {
130+
$this->assertSame($expected, $factory($value)->describe(VerbosityLevel::precise()));
131+
} finally {
132+
restore_error_handler();
133+
}
134+
}
135+
85136
}

‎turbo-ext/src/IntegerRangeType.cpp‎

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ namespace sigs = ptdecl::IntegerRangeType::sig;
2727

2828
zend_class_entry *pt_ce_integer_range_type = nullptr;
2929

30+
/* the twin's private const FLOAT_ABOVE_INT_MAX = PHP_INT_MAX + 1.0: the first
31+
* double above ZEND_LONG_MAX, exact on 64-bit (2^63) and 32-bit (2^31) builds */
32+
static constexpr double PT_IRT_FLOAT_ABOVE_INT_MAX = (double) ZEND_LONG_MAX + 1.0;
33+
3034
/* nothing is memoized per request since InitializerExprTypeResolver's
3135
* CALCULATE_SCALARS_LIMIT became a native constant */
3236
void pt_integer_range_type_rinit()
@@ -171,26 +175,26 @@ class IntegerRangeType
171175

172176
/* the createAll*() family: the range of integers beyond an int|float
173177
* $value. An int goes straight to fromInterval(); anything else is
174-
* first held against the int limits — a float the way the VM compares
175-
* int with float (both as doubles), any other value loosely — and what
176-
* passes is then rounded by ceil()/floor() and (int)-cast, which is
177-
* where a non-number fails under strict_types. */
178+
* held against the int limits — a float the way the VM compares int
179+
* with float (both as doubles), any other value loosely — and rounded
180+
* by ceil()/floor() and (int)-cast, which is where a non-number fails
181+
* under strict_types. Where the ceil() is held against the max instead
182+
* of $value (createAllSmallerThan(), createAllGreaterThanOrEqualTo()),
183+
* it is taken first, in the twin's order. */
178184

179185
/* fromInterval(null, $value, -1) */
180186
static zv::Val createAllSmallerThan(zval *value)
181187
{
182188
if (Z_TYPE_P(value) == IS_LONG) return fromInterval(NullableLong::null(), NullableLong::of(Z_LVAL_P(value)), -1);
183-
int againstMax, againstMin;
184-
if (UNEXPECTED(!compareToLimits(value, againstMax, againstMin))) return zv::Val();
185-
if (againstMax >= 0) { /* $value >= PHP_INT_MAX */
189+
double ceiled;
190+
if (UNEXPECTED(!roundToDouble(value, true, ceiled))) return zv::Val();
191+
if (ceiled >= PT_IRT_FLOAT_ABOVE_INT_MAX) { /* $ceil >= self::FLOAT_ABOVE_INT_MAX */
186192
return integer();
187193
}
188-
if (againstMin <= 0) { /* $value <= PHP_INT_MIN */
194+
if (Z_DVAL_P(value) <= (double) ZEND_LONG_MIN) { /* $value <= PHP_INT_MIN */
189195
return never();
190196
}
191-
zend_long rounded;
192-
if (UNEXPECTED(!roundToLong(value, true, rounded))) return zv::Val();
193-
return fromInterval(NullableLong::null(), NullableLong::of(rounded), -1);
197+
return fromInterval(NullableLong::null(), NullableLong::of(zend_dval_to_lval(ceiled)), -1);
194198
}
195199

196200
/* fromInterval(null, $value) */
@@ -231,17 +235,17 @@ class IntegerRangeType
231235
static zv::Val createAllGreaterThanOrEqualTo(zval *value)
232236
{
233237
if (Z_TYPE_P(value) == IS_LONG) return fromInterval(NullableLong::of(Z_LVAL_P(value)), NullableLong::null(), 0);
234-
int againstMax, againstMin;
235-
if (UNEXPECTED(!compareToLimits(value, againstMax, againstMin))) return zv::Val();
238+
int againstMin;
239+
if (UNEXPECTED(!compareToMin(value, againstMin))) return zv::Val();
236240
if (againstMin <= 0) { /* $value <= PHP_INT_MIN */
237241
return integer();
238242
}
239-
if (againstMax >= 0) { /* $value >= PHP_INT_MAX */
243+
double ceiled;
244+
if (UNEXPECTED(!roundToDouble(value, true, ceiled))) return zv::Val();
245+
if (ceiled >= PT_IRT_FLOAT_ABOVE_INT_MAX) { /* $ceil >= self::FLOAT_ABOVE_INT_MAX */
240246
return never();
241247
}
242-
zend_long rounded;
243-
if (UNEXPECTED(!roundToLong(value, true, rounded))) return zv::Val();
244-
return fromInterval(NullableLong::of(rounded), NullableLong::null(), 0);
248+
return fromInterval(NullableLong::of(zend_dval_to_lval(ceiled)), NullableLong::null(), 0);
245249
}
246250

247251
/* $this->min / $this->max; false with an Error pending when the
@@ -1127,34 +1131,58 @@ class IntegerRangeType
11271131
* every one of the four tests false); anything else compares loosely.
11281132
* false = pending exception */
11291133
[[nodiscard]] static bool compareToLimits(zval *value, int &againstMax, int &againstMin)
1134+
{
1135+
return compareToMax(value, againstMax) && compareToMin(value, againstMin);
1136+
}
1137+
1138+
[[nodiscard]] static bool compareToMax(zval *value, int &againstMax)
11301139
{
11311140
if (EXPECTED(Z_TYPE_P(value) == IS_DOUBLE)) {
11321141
double d = Z_DVAL_P(value);
11331142
double max = (double) ZEND_LONG_MAX;
1134-
double min = (double) ZEND_LONG_MIN;
11351143
againstMax = d > max ? 1 : (d < max ? -1 : (d == max ? 0 : -1));
1136-
againstMin = d < min ? -1 : (d > min ? 1 : (d == min ? 0 : 1));
11371144
return true;
11381145
}
11391146
zval limit;
11401147
ZVAL_LONG(&limit, ZEND_LONG_MAX);
11411148
againstMax = zend_compare(value, &limit);
1142-
if (UNEXPECTED(EG(exception))) return false;
1149+
return !EG(exception);
1150+
}
1151+
1152+
[[nodiscard]] static bool compareToMin(zval *value, int &againstMin)
1153+
{
1154+
if (EXPECTED(Z_TYPE_P(value) == IS_DOUBLE)) {
1155+
double d = Z_DVAL_P(value);
1156+
double min = (double) ZEND_LONG_MIN;
1157+
againstMin = d < min ? -1 : (d > min ? 1 : (d == min ? 0 : 1));
1158+
return true;
1159+
}
1160+
zval limit;
11431161
ZVAL_LONG(&limit, ZEND_LONG_MIN);
11441162
againstMin = zend_compare(value, &limit);
11451163
return !EG(exception);
11461164
}
11471165

1148-
/* (int) ceil($value) / (int) floor($value) for a non-int $value: a
1149-
* float rounds and casts as the engine casts; anything else is what
1150-
* ceil()/floor() reject under strict_types. false = pending exception */
1151-
[[nodiscard]] static bool roundToLong(zval *value, bool useCeil, zend_long &out)
1166+
/* ceil($value) / floor($value) for a non-int $value: a float rounds;
1167+
* anything else is what ceil()/floor() reject under strict_types.
1168+
* false = pending exception */
1169+
[[nodiscard]] static bool roundToDouble(zval *value, bool useCeil, double &out)
11521170
{
11531171
if (UNEXPECTED(Z_TYPE_P(value) != IS_DOUBLE)) {
11541172
zend_type_error("%s(): Argument #1 ($num) must be of type int|float, %s given", useCeil ? "ceil" : "floor", zend_zval_value_name(value));
11551173
return false;
11561174
}
1157-
out = zend_dval_to_lval(useCeil ? ceil(Z_DVAL_P(value)) : floor(Z_DVAL_P(value)));
1175+
out = useCeil ? ceil(Z_DVAL_P(value)) : floor(Z_DVAL_P(value));
1176+
return true;
1177+
}
1178+
1179+
/* (int) ceil($value) / (int) floor($value) for a non-int $value, cast as
1180+
* the engine casts. false = pending exception */
1181+
[[nodiscard]] static bool roundToLong(zval *value, bool useCeil, zend_long &out)
1182+
{
1183+
double rounded;
1184+
if (UNEXPECTED(!roundToDouble(value, useCeil, rounded))) return false;
1185+
out = zend_dval_to_lval(rounded);
11581186
return true;
11591187
}
11601188

@@ -1334,13 +1362,19 @@ static void pt_irt_bound(INTERNAL_FUNCTION_PARAMETERS, bool (IntegerRangeType::*
13341362
RETURN_LONG(bound.value);
13351363
}
13361364

1365+
static void pt_irt_float_above_int_max_constant(zval *out)
1366+
{
1367+
ZVAL_DOUBLE(out, PT_IRT_FLOAT_ABOVE_INT_MAX);
1368+
}
1369+
13371370
PT_MINIT_REGISTRATION(pt_register_integer_range_type)
13381371
{
13391372
reg::Class cls("PHPStan\\Type\\IntegerRangeType");
13401373
ptdecl::IntegerRangeType::declareClass(cls);
13411374
/* "min" and "max" must stay the first two declared properties
13421375
* (slots::min, slots::max) */
13431376
ptdecl::IntegerRangeType::declareProperties(cls);
1377+
cls.privateClassConstantValue("FLOAT_ABOVE_INT_MAX", pt_irt_float_above_int_max_constant);
13441378

13451379
cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) {
13461380
zend_long min, max;

‎turbo-ext/tests/type-family.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,13 +353,13 @@
353353
}
354354
}
355355
}
356-
foreach ([0, 1, -1, 42, PHP_INT_MAX, PHP_INT_MIN, PHP_INT_MAX - 1, PHP_INT_MIN + 1, 0.0, 0.5, -0.5, 2.5, -2.5, 1e30, -1e30, 9.2233720368547758E+18, -9.2233720368547758E+18, 9.2e18, -9.2e18, (float) PHP_INT_MAX, (float) PHP_INT_MIN, NAN, INF, -INF] as $value) {
356+
foreach ([0, 1, -1, 42, PHP_INT_MAX, PHP_INT_MIN, PHP_INT_MAX - 1, PHP_INT_MIN + 1, 0.0, 0.5, -0.5, 2.5, -2.5, 1e30, -1e30, 9.2233720368547758E+18, -9.2233720368547758E+18, 9.2e18, -9.2e18, (float) PHP_INT_MAX, (float) PHP_INT_MIN, PHP_INT_MAX + 1.0, 9223372036854774784.0, 2147483647.0, 2147483646.5, 2147483647.5, -2147483648.0, -2147483648.5, NAN, INF, -INF] as $value) {
357357
$key = is_float($value) ? var_export($value, true) : (string) $value;
358358
foreach (['createAllSmallerThan', 'createAllSmallerThanOrEqualTo', 'createAllGreaterThan', 'createAllGreaterThanOrEqualTo'] as $factory) {
359359
$r["$factory $key"] = @$view($rangeClass::$factory($value));
360360
}
361361
}
362-
foreach (['5', true, null] as $value) {
362+
foreach (['5', '1e30', '-1e30', true, null] as $value) {
363363
foreach (['createAllSmallerThan', 'createAllGreaterThanOrEqualTo'] as $factory) {
364364
try {
365365
$r["$factory " . var_export($value, true)] = $view($rangeClass::$factory($value));

0 commit comments

Comments
 (0)