Make WordPress Core

Changeset 62474


Ignore:
Timestamp:
06/08/2026 09:24:17 PM (3 months ago)
Author:
westonruter
Message:

REST API: Fix rest_is_integer() returning false for large integers.

The previous round( (float) $maybe_integer ) === (float) $maybe_integer check rejected large integers on PHP 8.4. The check itself was fragile: a PHP float (a 64-bit IEEE-754 double) can represent every integer exactly only up to 253, so casting larger values is lossy. Nevertheless, that lossiness alone did not reject anything, since both sides of the comparison were munged identically. What actually broke it was a round() regression in PHP 8.4, where round( (float) $x ) can return a value different from (float) $x for certain numbers. That inequality caused canonical integers still valid for a BIGINT UNSIGNED column (such as unusually high post IDs) to be incorrectly rejected by REST validation, only on PHP 8.4+.

The function now short-circuits returning true for native integers and canonical integer strings so that integer-like values of any magnitude are detected correctly. Decimal and scientific-notation strings (and floats) retain their historical behavior, including the existing float comparison, now rewritten as a floor() check whose strict equality compares a float to its own floor and is therefore exact. The limitations around PHP_INT_MAX and fractional magnitudes beyond 2 ** 53 are documented on the function, and the data provider gains coverage for large integers, negative floats, and scientific notation.

Developed in https://github.com/WordPress/wordpress-develop/pull/11893.
Follow-up to r48306.

Props siliconforks, gautam23, westonruter, kevinfodness, mboynes, desrosj.
Fixes #65271.

Location:
trunk
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-includes/rest-api.php

    r62334 r62474  
    15651565 * Determines if a given value is integer-like.
    15661566 *
     1567 * This reports whether the value represents an integer; it does not guarantee that the
     1568 * value can be represented as a native PHP integer. Values whose magnitude exceeds
     1569 * `PHP_INT_MAX` are still reported as integer-like, even though the `(int)` cast that
     1570 * {@see rest_sanitize_value_from_schema()} applies for the 'integer' type cannot round-trip
     1571 * them: an out-of-range numeric *string* saturates to `PHP_INT_MAX` or `PHP_INT_MIN`, while
     1572 * an out-of-range *float* is an undefined conversion in PHP that yields an arbitrary wrapped
     1573 * value. Likewise, a numeric value with a fractional part that is too large for the fraction
     1574 * to be represented as a float (greater than 2 ** 53) is reported as integer-like.
     1575 *
    15671576 * @since 5.5.0
    15681577 *
     
    15701579 * @return bool True if an integer, otherwise false.
    15711580 */
    1572 function rest_is_integer( $maybe_integer ) {
    1573         return is_numeric( $maybe_integer ) && round( (float) $maybe_integer ) === (float) $maybe_integer;
     1581function rest_is_integer( $maybe_integer ): bool {
     1582        if ( is_int( $maybe_integer ) ) {
     1583                return true;
     1584        }
     1585
     1586        // A canonical integer string of any magnitude — verified without float conversion.
     1587        if ( is_string( $maybe_integer ) && preg_match( '/^\s*[+-]?[0-9]+\s*$/', $maybe_integer ) ) {
     1588                return true;
     1589        }
     1590
     1591        // Decimal and scientific-notation strings (and floats) keep their historical behavior.
     1592        if ( ! is_numeric( $maybe_integer ) ) {
     1593                return false;
     1594        }
     1595        $float_value = (float) $maybe_integer;
     1596
     1597        /*
     1598         * The strict equality here is not the unreliable "are two computed floats equal" comparison
     1599         * (e.g. 0.1 + 0.2 === 0.3, which is false). It compares a float to its own floor() to ask
     1600         * "does this float have a fractional part?". A float is whole exactly when it equals its floor,
     1601         * so the comparison is exact and safe regardless of floating-point representation error.
     1602         */
     1603        return floor( $float_value ) === $float_value;
    15741604}
    15751605
  • trunk/tests/phpunit/tests/rest-api.php

    r60104 r62474  
    22912291
    22922292        /**
     2293         * Tests rest_is_integer().
     2294         *
    22932295         * @ticket 51146
     2296         * @ticket 65271
    22942297         *
    22952298         * @dataProvider data_rest_is_integer
    22962299         *
    2297          * @param bool  $expected Expected result of the check.
    2298          * @param mixed $value    The value to check.
    2299          */
    2300         public function test_rest_is_integer( $expected, $value ) {
     2300         * @param bool     $expected_is_integer Expected result of the check.
     2301         * @param mixed    $value               The value to check.
     2302         * @param int|null $expected_sanitized  For integer-like values, the integer that
     2303         *                                      {@see rest_sanitize_value_from_schema()} should return.
     2304         *                                      A value of null means the value is integer-like but its
     2305         *                                      sanitized result is not checked, because the value is too
     2306         *                                      large to reason about: the `(int)` cast of an out-of-range
     2307         *                                      float is undefined in PHP, so the result is unspecified.
     2308         *
     2309         * @covers ::rest_is_integer
     2310         * @covers ::rest_sanitize_value_from_schema
     2311         */
     2312        public function test_rest_is_integer( bool $expected_is_integer, $value, ?int $expected_sanitized = null ): void {
    23012313                $is_integer = rest_is_integer( $value );
    23022314
    2303                 if ( $expected ) {
     2315                if ( $expected_is_integer ) {
    23042316                        $this->assertTrue( $is_integer );
     2317
     2318                        /*
     2319                         * Validation and sanitization must agree: any value treated as integer-like
     2320                         * must also be sanitized to the expected integer by the 'integer' type,
     2321                         * without the value being munged by the (int) cast. This is skipped when
     2322                         * $expected_sanitized is null, since the sanitized result of an out-of-range
     2323                         * float is undefined and therefore not worth asserting.
     2324                         */
     2325                        if ( null !== $expected_sanitized ) {
     2326                                $sanitized = rest_sanitize_value_from_schema( $value, array( 'type' => 'integer' ) );
     2327                                $this->assertSame(
     2328                                        $expected_sanitized,
     2329                                        $sanitized,
     2330                                        'Sanitization should return the expected integer without munging the value.'
     2331                                );
     2332                        }
    23052333                } else {
    23062334                        $this->assertFalse( $is_integer );
     
    23082336        }
    23092337
    2310         public function data_rest_is_integer() {
     2338        /**
     2339         * Data provider for {@see self::test_rest_is_integer()}.
     2340         *
     2341         * Integer-like rows include a third element: the integer that
     2342         * rest_sanitize_value_from_schema() should produce for the value.
     2343         *
     2344         * @return list<array<int, mixed>>
     2345         *
     2346         * @phpstan-return list<array{
     2347         *     0: bool,  // $expected_is_integer
     2348         *     1: mixed, // $value
     2349         *     2?: int,  // $expected_sanitized
     2350         * }>
     2351         */
     2352        public function data_rest_is_integer(): array {
    23112353                return array(
    23122354                        array(
    23132355                                true,
    23142356                                1,
     2357                                1,
    23152358                        ),
    23162359                        array(
    23172360                                true,
    23182361                                '1',
     2362                                1,
    23192363                        ),
    23202364                        array(
    23212365                                true,
    23222366                                0,
     2367                                0,
    23232368                        ),
    23242369                        array(
    23252370                                true,
    23262371                                -1,
     2372                                -1,
    23272373                        ),
    23282374                        array(
    23292375                                true,
    23302376                                '05',
     2377                                5,
    23312378                        ),
    23322379                        array(
     
    23442391                        array(
    23452392                                false,
     2393                                -5.5,
     2394                        ),
     2395                        array(
     2396                                false,
     2397                                '-5.5',
     2398                        ),
     2399                        array(
     2400                                false,
    23462401                                array(),
    23472402                        ),
     
    23492404                                false,
    23502405                                true,
     2406                        ),
     2407                        array(
     2408                                true,
     2409                                '15e0',
     2410                                15,
     2411                        ),
     2412                        array(
     2413                                true,
     2414                                '15e+0',
     2415                                15,
     2416                        ),
     2417                        array(
     2418                                true,
     2419                                '15e-0',
     2420                                15,
     2421                        ),
     2422                        array(
     2423                                false,
     2424                                '15e-1',
     2425                        ),
     2426
     2427                        /*
     2428                         * Integer-valued floats and decimal strings are accepted for back-compatibility.
     2429                         * Each of these also round-trips cleanly through the (int) cast performed by
     2430                         * rest_sanitize_value_from_schema() for the 'integer' type.
     2431                         */
     2432                        array(
     2433                                true,
     2434                                1.0,
     2435                                1,
     2436                        ),
     2437                        array(
     2438                                true,
     2439                                5.0,
     2440                                5,
     2441                        ),
     2442                        array(
     2443                                true,
     2444                                '1.0',
     2445                                1,
     2446                        ),
     2447                        array(
     2448                                true,
     2449                                '5.0',
     2450                                5,
     2451                        ),
     2452                        array(
     2453                                true,
     2454                                1.5e3,
     2455                                1500,
     2456                        ),
     2457                        array(
     2458                                true,
     2459                                '1.5e3',
     2460                                1500,
     2461                        ),
     2462                        array(
     2463                                true,
     2464                                '15e2',
     2465                                1500,
     2466                        ),
     2467
     2468                        // Signed canonical integer strings.
     2469                        array(
     2470                                true,
     2471                                '+5',
     2472                                5,
     2473                        ),
     2474                        array(
     2475                                true,
     2476                                '-5',
     2477                                -5,
     2478                        ),
     2479
     2480                        // Non-numeric and non-string scalars are not integers.
     2481                        array(
     2482                                false,
     2483                                false,
     2484                        ),
     2485                        array(
     2486                                false,
     2487                                null,
     2488                        ),
     2489
     2490                        // The following values test very large integers.
     2491                        array(
     2492                                true,
     2493                                2 ** 52,
     2494                                2 ** 52,
     2495                        ),
     2496                        array(
     2497                                true,
     2498                                2 ** 52 + 2,
     2499                                2 ** 52 + 2,
     2500                        ),
     2501                        array(
     2502                                true,
     2503                                '4503599627370496', // 2 ** 52
     2504                                4503599627370496,
     2505                        ),
     2506                        array(
     2507                                true,
     2508                                '4503599627370498', // 2 ** 52 + 2
     2509                                4503599627370498,
     2510                        ),
     2511                        array(
     2512                                true,
     2513                                '-4503599627370498', // -( 2 ** 52 + 2 ), a large negative integer string.
     2514                                -4503599627370498,
     2515                        ),
     2516                        array(
     2517                                true,
     2518                                '4611686018427387904', // 2 ** 62, a large positive integer string below PHP_INT_MAX.
     2519                                4611686018427387904,
     2520                        ),
     2521
     2522                        /*
     2523                         * Out-of-range floats are reported as integer-like, but their sanitized value is
     2524                         * not asserted: integer arithmetic overflow promotes the result to a float, and the
     2525                         * subsequent (int) cast of an out-of-range float is undefined in PHP. The null
     2526                         * $expected_sanitized signals that the sanitized result should not be checked.
     2527                         */
     2528                        array(
     2529                                true,
     2530                                PHP_INT_MAX + 1, // Integer overflow promotes to a float greater than PHP_INT_MAX.
     2531                                null,
     2532                        ),
     2533                        array(
     2534                                true,
     2535                                PHP_INT_MIN - 1, // Integer overflow promotes to a float less than PHP_INT_MIN.
     2536                                null,
     2537                        ),
     2538
     2539                        /*
     2540                         * Canonical integer strings beyond the native integer range are reported as
     2541                         * integer-like, and unlike floats their (int) cast is well-defined: it saturates
     2542                         * to PHP_INT_MAX or PHP_INT_MIN, so the sanitized value can be asserted.
     2543                         */
     2544                        array(
     2545                                true,
     2546                                PHP_INT_MAX . '000', // A string three orders of magnitude above PHP_INT_MAX, whatever the word size.
     2547                                PHP_INT_MAX,         // (int) cast of an out-of-range numeric string saturates.
     2548                        ),
     2549                        array(
     2550                                true,
     2551                                PHP_INT_MIN . '000', // A string three orders of magnitude below PHP_INT_MIN, whatever the word size.
     2552                                PHP_INT_MIN,         // (int) cast of an out-of-range numeric string saturates.
    23512553                        ),
    23522554                );
Note: See TracChangeset for help on using the changeset viewer.