From cc1d30995ada1c93b833b0f93d86867be26fdee1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 31 Aug 2026 12:48:18 +0400 Subject: [PATCH 01/13] I18N: Ignore non-string values in the locale getters. `get_locale()`, `get_user_locale()` and `determine_locale()` are each documented to return a string, and each takes its value from a source that carries no type: the `WPLANG` option and site option, the `WPLANG` constant, the `$locale` and `$wp_local_package` globals, the `locale` user meta row, the `wp_lang` request parameter and cookie, and the `locale`, `option_WPLANG` and `determine_locale` filters. Only `empty()` and truthiness stood in the way, and a non-empty array passes both. A non-string locale reaches `WP_Textdomain_Registry::set()`, which uses it as an array key, so the first just-in-time translation for an unloaded text domain ends the request with "Cannot access offset of type array on array". On `wp-login.php` that needs no authentication: `sanitize_locale_name()` applies `preg_replace()`, which maps over an array subject and returns an array, so `?wp_lang[]=de_DE` carries one straight through. Add `is_string()` beside the non-empty checks that are already there, and fall back the way each function already falls back: to `en_US` for the site locale, to `get_locale()` for the user locale, and to the unfiltered value when a filter returns something else. Shape is left alone; only the type and emptiness are checked, so locales that work today keep working. Also ignore a non-string `locale` field in `wp_insert_user()`, the one core write path that stored it unchecked. That does not repair rows already in the database, which is why the read side is guarded too. --- src/wp-includes/l10n.php | 54 ++++- src/wp-includes/user.php | 4 +- tests/phpunit/tests/l10n/determineLocale.php | 146 +++++++++++++ tests/phpunit/tests/l10n/getLocale.php | 204 +++++++++++++++++++ tests/phpunit/tests/l10n/getUserLocale.php | 56 +++++ tests/phpunit/tests/user.php | 42 ++++ 6 files changed, 499 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 0f9dd0d4016a0..18f67432d3f5f 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -21,6 +21,7 @@ * always be filtered using the {@see 'locale'} hook. * * @since 1.5.0 + * @since 7.2.0 Non-string values are ignored. * * @global string $locale The current locale. * @global string $wp_local_package Locale code of the package. @@ -31,8 +32,18 @@ function get_locale() { global $locale, $wp_local_package; if ( isset( $locale ) ) { + if ( empty( $locale ) || ! is_string( $locale ) ) { + $locale = 'en_US'; + } + /** This filter is documented in wp-includes/l10n.php */ - return apply_filters( 'locale', $locale ); + $filtered_locale = apply_filters( 'locale', $locale ); + + if ( empty( $filtered_locale ) || ! is_string( $filtered_locale ) ) { + return $locale; + } + + return $filtered_locale; } if ( isset( $wp_local_package ) ) { @@ -66,18 +77,30 @@ function get_locale() { } } - if ( empty( $locale ) ) { + /* + * The value may have come from an option, a constant or a global, none of + * which guarantee a type. Callers are documented to receive a string. + */ + if ( empty( $locale ) || ! is_string( $locale ) ) { $locale = 'en_US'; } /** * Filters the locale ID of the WordPress installation. * + * A value that is not a non-empty string is ignored. + * * @since 1.5.0 * * @param string $locale The locale ID. */ - return apply_filters( 'locale', $locale ); + $filtered_locale = apply_filters( 'locale', $locale ); + + if ( empty( $filtered_locale ) || ! is_string( $filtered_locale ) ) { + return $locale; + } + + return $filtered_locale; } /** @@ -87,6 +110,7 @@ function get_locale() { * returned. Otherwise it returns the locale of get_locale(). * * @since 4.7.0 + * @since 7.2.0 A non-string `locale` user meta value is ignored. * * @param int|WP_User $user User's ID or a WP_User object. Defaults to current user. * @return string The locale of the user. @@ -106,15 +130,25 @@ function get_user_locale( $user = 0 ) { return get_locale(); } + /* + * WP_User has no `locale` property. Reading it runs + * get_user_meta( $user_id, 'locale', true ), so this is a read of untyped + * storage and the row may hold anything, including an array. + */ $locale = $user_object->locale; - return $locale ? $locale : get_locale(); + if ( empty( $locale ) || ! is_string( $locale ) ) { + return get_locale(); + } + + return $locale; } /** * Determines the current locale desired for the request. * * @since 5.0.0 + * @since 7.2.0 Non-string values are ignored. * * @global string $pagenow The filename of the current screen. * @global string $wp_local_package Locale code of the package. @@ -162,18 +196,26 @@ function determine_locale() { } } - if ( ! $determined_locale ) { + if ( empty( $determined_locale ) || ! is_string( $determined_locale ) ) { $determined_locale = get_locale(); } /** * Filters the locale for the current request. * + * A value that is not a non-empty string is ignored. + * * @since 5.0.0 * * @param string $determined_locale The locale. */ - return apply_filters( 'determine_locale', $determined_locale ); + $filtered_locale = apply_filters( 'determine_locale', $determined_locale ); + + if ( empty( $filtered_locale ) || ! is_string( $filtered_locale ) ) { + return $determined_locale; + } + + return $filtered_locale; } /** diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 01b567a74d86d..07cc7af16a4c8 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2169,6 +2169,7 @@ function validate_username( $username ) { * @since 5.3.0 The `user_activation_key` field can be passed to `$userdata`. * @since 5.3.0 The `spam` field can be passed to `$userdata` (Multisite only). * @since 5.9.0 The `meta_input` field can be passed to `$userdata` to allow addition of user meta data. + * @since 7.2.0 A non-string `locale` field is ignored. * * @global wpdb $wpdb WordPress database abstraction object. * @@ -2523,7 +2524,8 @@ function wp_insert_user( $userdata ) { $meta['show_admin_bar_front'] = empty( $userdata['show_admin_bar_front'] ) ? 'true' : $userdata['show_admin_bar_front']; - $meta['locale'] = $userdata['locale'] ?? ''; + // The row is read back by get_user_locale(), which is documented to return a string. + $meta['locale'] = isset( $userdata['locale'] ) && is_string( $userdata['locale'] ) ? $userdata['locale'] : ''; $compacted = compact( 'user_pass', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_activation_key', 'display_name' ); $data = wp_unslash( $compacted ); diff --git a/tests/phpunit/tests/l10n/determineLocale.php b/tests/phpunit/tests/l10n/determineLocale.php index ca8c0bafeccb7..bbc6dbace4141 100644 --- a/tests/phpunit/tests/l10n/determineLocale.php +++ b/tests/phpunit/tests/l10n/determineLocale.php @@ -308,4 +308,150 @@ public function test_wp_local_package_global_installing() { wp_installing( true ); $this->assertSame( 'de_DE', determine_locale() ); } + + /** + * sanitize_locale_name() applies preg_replace(), which maps over an array + * subject and returns an array, so `wp-login.php?wp_lang[]=de_DE` reaches + * the return statement with an array. No authentication is needed. + * + * @dataProvider data_array_request_value + * + * @param array $value Array request value. + */ + public function test_wp_login_get_param_on_login_page_array( $value ) { + $GLOBALS['pagenow'] = 'wp-login.php'; + $_GET['wp_lang'] = $value; + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * @dataProvider data_array_request_value + * + * @param array $value Array request value. + */ + public function test_wp_login_cookie_on_login_page_array( $value ) { + $GLOBALS['pagenow'] = 'wp-login.php'; + $_COOKIE['wp_lang'] = $value; + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * @dataProvider data_array_request_value + * + * @param array $value Array request value. + */ + public function test_language_param_installing_array( $value ) { + $_REQUEST['language'] = $value; + wp_installing( true ); + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * An array locale reaches WP_Textdomain_Registry::set(), which uses it as + * an array key and throws a TypeError, so translating any string for an + * unloaded text domain takes down the login page for an anonymous visitor. + */ + public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_registry() { + $GLOBALS['pagenow'] = 'wp-login.php'; + $_GET['wp_lang'] = array( 'de_DE' ); + + $this->assertSame( 'Some text', __( 'Some text', 'my-login-plugin' ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_array_request_value() { + // An empty array is falsy, so it never reaches sanitize_locale_name(). + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'lang' => 'de_DE' ) ), + ); + } + + /** + * The `$wp_local_package` global is untyped and only checked for truthiness. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_wp_local_package_global_installing_non_string( $value ) { + $GLOBALS['wp_local_package'] = $value; + wp_installing( true ); + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * The `determine_locale` filter result is returned unchecked, unlike + * `pre_determine_locale`, which is guarded with is_string(). + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_ignores_a_non_string_determine_locale_filter( $value ) { + add_filter( + 'determine_locale', + static function () use ( $value ) { + return $value; + } + ); + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * An array `locale` user meta row reaches determine_locale() through + * get_user_locale() on every admin request. + * + * @dataProvider data_non_string_user_locale_meta + * + * @param mixed $meta_value Value stored in the `locale` user meta row. + */ + public function test_returns_a_string_for_a_non_string_user_locale_meta( $meta_value ) { + set_current_screen( 'dashboard' ); + wp_set_current_user( self::$user_id ); + update_user_meta( self::$user_id, 'locale', $meta_value ); + + $this->assertSame( 'en_US', determine_locale() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_locale() { + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + 'an integer' => array( 1234 ), + 'a float' => array( 1.5 ), + 'true' => array( true ), + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_user_locale_meta() { + // Scalars survive the meta round trip as strings, so only arrays and + // objects can come back from get_user_meta() with the wrong type. + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + ); + } } diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index bebae56316b87..63e79b91991fb 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -90,4 +90,208 @@ public function test_should_respect_get_locale_filter() { public function filter_get_locale() { return 'foo'; } + + /** + * The `WPLANG` option is untyped storage. sanitize_option() rejects a + * non-string on the way in, but nothing checks it on the way out, so a row + * written by a direct database query survives to the return value: a + * non-empty array passes both `false !== $db_locale` and `empty( $locale )`. + * + * @group ms-excluded + * + * @dataProvider data_stored_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ) { + global $locale; + $old_locale = $locale; + $locale = null; + + $this->write_raw_option_row( 'WPLANG', $value ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * @group ms-required + * + * @dataProvider data_stored_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $value ) { + global $locale, $wpdb; + $old_locale = $locale; + $locale = null; + + update_site_option( 'WPLANG', 'en_US' ); + $wpdb->update( + $wpdb->sitemeta, + array( 'meta_value' => maybe_serialize( $value ) ), + array( + 'site_id' => get_current_network_id(), + 'meta_key' => 'WPLANG', + ) + ); + wp_cache_flush(); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * The `$locale` global short-circuits the function before any of its own + * guards run. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $value ) { + global $locale; + $old_locale = $locale; + $locale = $value; + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * The `locale` filter result is returned unchecked. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_ignore_a_non_string_locale_filter( $value ) { + global $locale; + $old_locale = $locale; + $locale = null; + + add_filter( + 'locale', + static function () use ( $value ) { + return $value; + } + ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * The `locale` filter also runs on the `$locale` global short-circuit path. + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_ignore_a_non_string_locale_filter_on_the_global_path( $value ) { + global $locale; + $old_locale = $locale; + $locale = 'es_ES'; + + add_filter( + 'locale', + static function () use ( $value ) { + return $value; + } + ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'es_ES', $found ); + } + + /** + * The `option_WPLANG` filter runs after the option is read. + * + * @group ms-excluded + * + * @dataProvider data_non_string_locale + * + * @param mixed $value Non-string value. + */ + public function test_should_fall_back_on_en_US_for_a_non_string_option_filter( $value ) { + global $locale; + $old_locale = $locale; + $locale = null; + + add_filter( + 'option_WPLANG', + static function () use ( $value ) { + return $value; + } + ); + + $found = get_locale(); + $locale = $old_locale; + + $this->assertSame( 'en_US', $found ); + } + + /** + * Writes an option row without going through sanitize_option(), the way a + * migration or a direct database query would. + * + * @param string $option Option name. + * @param mixed $value Value to store. + */ + private function write_raw_option_row( $option, $value ) { + global $wpdb; + + $wpdb->replace( + $wpdb->options, + array( + 'option_name' => $option, + 'option_value' => maybe_serialize( $value ), + ) + ); + + wp_cache_flush(); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_locale() { + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + 'an integer' => array( 1234 ), + 'a float' => array( 1.5 ), + 'true' => array( true ), + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_stored_non_string_locale() { + // Scalars survive the storage round trip as strings, so only arrays and + // objects can come back from an option with the wrong type. + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + ); + } } diff --git a/tests/phpunit/tests/l10n/getUserLocale.php b/tests/phpunit/tests/l10n/getUserLocale.php index e4eaf7a2601bc..6c494a3bbac22 100644 --- a/tests/phpunit/tests/l10n/getUserLocale.php +++ b/tests/phpunit/tests/l10n/getUserLocale.php @@ -146,4 +146,60 @@ public function test_user_id_argument_with_invalid_type() { $user_locale = get_user_locale( 'string' ); $this->assertSame( get_locale(), $user_locale ); } + + /** + * A `locale` user meta row holding an array is truthy, so the `empty()`-style + * guard passes it through and callers receive an array where the documented + * return type is a string. + * + * @dataProvider data_non_string_user_locale_meta + * + * @param mixed $meta_value Value stored in the `locale` user meta row. + */ + public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_value ) { + set_current_screen( 'dashboard' ); + update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); + + $this->assertSame( get_locale(), get_user_locale() ); + } + + /** + * @dataProvider data_non_string_user_locale_meta + * + * @param mixed $meta_value Value stored in the `locale` user meta row. + */ + public function test_returns_a_string_for_non_string_user_locale_meta( $meta_value ) { + set_current_screen( 'dashboard' ); + update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); + + $this->assertIsString( get_user_locale() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_user_locale_meta() { + // Scalars survive the meta round trip as strings, so only arrays and + // objects can come back from get_user_meta() with the wrong type. + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + ); + } + + /** + * An array locale reaches WP_Textdomain_Registry::set(), which uses it as an + * array key and throws a TypeError, so translating any string for an + * unloaded text domain takes down the request. + */ + public function test_array_user_locale_meta_does_not_fatal_in_the_textdomain_registry() { + set_current_screen( 'dashboard' ); + update_user_meta( self::$administrator_de_de, 'locale', array( 'de_DE' ) ); + + $this->assertSame( 'Some text', __( 'Some text', 'my-plugin' ) ); + } } diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index f600adbcb1164..a5a306a3e42ad 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -1296,6 +1296,48 @@ public function test_wp_insert_user_should_not_wipe_existing_password() { $this->assertNotEmpty( $user->user_pass ); } + /** + * wp_insert_user() stores `$userdata['locale']` as user meta with no type + * check, so a caller can plant a value that get_user_locale() later returns + * where a string is documented. + * + * @dataProvider data_non_string_locale + * + * @covers ::wp_insert_user + * @covers ::wp_update_user + * + * @param mixed $locale Non-string locale. + */ + public function test_wp_insert_user_should_ignore_a_non_string_locale( $locale ) { + $user_id = self::factory()->user->create( array( 'locale' => 'de_DE' ) ); + + wp_update_user( + array( + 'ID' => $user_id, + 'locale' => $locale, + ) + ); + + $this->assertSame( '', get_user_meta( $user_id, 'locale', true ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_non_string_locale() { + return array( + 'a list' => array( array( 'de_DE' ) ), + 'a map' => array( array( 'locale' => 'de_DE' ) ), + 'an empty array' => array( array() ), + 'an object' => array( new stdClass() ), + 'an integer' => array( 1234 ), + 'a float' => array( 1.5 ), + 'true' => array( true ), + ); + } + /** * @ticket 29696 */ From 4e5a7416d8856d52443798225e52c18e541a4002 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 10 Sep 2026 09:30:59 +0400 Subject: [PATCH 02/13] Tests: Cover the wp_insert_user() create path for a non-string locale. The single test named for `wp_insert_user()` only called `wp_update_user()`, so nothing exercised the field on user creation, which is the path the guard was added for. Split it in two: one test creates a user with a non-string `locale` field, the other updates one that already holds `de_DE`. The update case keeps its assertion that the row ends up empty, and its docblock now says so: a non-string does not leave the stored locale in place, it empties the row, which is the value the field takes when it is not passed at all. --- tests/phpunit/tests/user.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index a5a306a3e42ad..28ba3198bc065 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -1304,11 +1304,28 @@ public function test_wp_insert_user_should_not_wipe_existing_password() { * @dataProvider data_non_string_locale * * @covers ::wp_insert_user + * + * @param mixed $locale Non-string locale. + */ + public function test_wp_insert_user_should_not_store_a_non_string_locale( $locale ) { + $user_id = self::factory()->user->create( array( 'locale' => $locale ) ); + + $this->assertSame( '', get_user_meta( $user_id, 'locale', true ) ); + } + + /** + * wp_update_user() passes the field on to wp_insert_user(). A non-string + * empties the row, the same value the field takes when it is not passed at + * all, rather than storing the non-string. + * + * @dataProvider data_non_string_locale + * + * @covers ::wp_insert_user * @covers ::wp_update_user * * @param mixed $locale Non-string locale. */ - public function test_wp_insert_user_should_ignore_a_non_string_locale( $locale ) { + public function test_wp_update_user_should_not_store_a_non_string_locale( $locale ) { $user_id = self::factory()->user->create( array( 'locale' => 'de_DE' ) ); wp_update_user( From c71443c1635ac2141ef1600b45c307be3583831e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 10 Sep 2026 09:31:31 +0400 Subject: [PATCH 03/13] Docs: Say what the locale guards do to an empty value. The `@since` lines named only non-string values, but the guards also changed what an empty value does: on the `$locale` global short-circuit in `get_locale()`, and on the return of the `locale` and `determine_locale` filters, an empty value now falls back where it used to be returned. `wp_insert_user()` does not ignore a non-string `locale` field either. It stores an empty string, which for an update means the row is emptied rather than left alone. Also name the writers of the `$locale` global at the guard that reads it. --- src/wp-includes/l10n.php | 12 ++++++++---- src/wp-includes/user.php | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index dece7f4b68c87..42743a71b9f7c 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -21,7 +21,7 @@ * always be filtered using the {@see 'locale'} hook. * * @since 1.5.0 - * @since 7.2.0 Non-string values are ignored. + * @since 7.2.0 Non-string and empty values are ignored. * * @global string $locale The current locale. * @global string $wp_local_package Locale code of the package. @@ -32,6 +32,10 @@ function get_locale() { global $locale, $wp_local_package; if ( isset( $locale ) ) { + /* + * The global is set by wp-config.php, by a plugin, or by this function + * on an earlier call, so it carries no type. + */ if ( empty( $locale ) || ! is_string( $locale ) ) { $locale = 'en_US'; } @@ -88,7 +92,7 @@ function get_locale() { /** * Filters the locale ID of the WordPress installation. * - * A value that is not a non-empty string is ignored. + * A non-string or empty value is ignored. * * @since 1.5.0 * @@ -148,7 +152,7 @@ function get_user_locale( $user = 0 ) { * Determines the current locale desired for the request. * * @since 5.0.0 - * @since 7.2.0 Non-string values are ignored. + * @since 7.2.0 Non-string and empty values are ignored. * * @global string $pagenow The filename of the current screen. * @global string $wp_local_package Locale code of the package. @@ -203,7 +207,7 @@ function determine_locale() { /** * Filters the locale for the current request. * - * A value that is not a non-empty string is ignored. + * A non-string or empty value is ignored. * * @since 5.0.0 * diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 3abdbcc125e94..6f54be11c9bd4 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2172,7 +2172,7 @@ function validate_username( $username ) { * @since 5.3.0 The `user_activation_key` field can be passed to `$userdata`. * @since 5.3.0 The `spam` field can be passed to `$userdata` (Multisite only). * @since 5.9.0 The `meta_input` field can be passed to `$userdata` to allow addition of user meta data. - * @since 7.2.0 A non-string `locale` field is ignored. + * @since 7.2.0 A non-string `locale` field is stored as an empty string. * * @global wpdb $wpdb WordPress database abstraction object. * From 5d064ff49adc5a78cede6116468b1ae60ca4295b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Thu, 10 Sep 2026 09:36:45 +0400 Subject: [PATCH 04/13] Tests: Make the option_WPLANG filter test able to fail. get_option() applies `option_{$option}` only when the option exists, and the test database has no `WPLANG` row, so the filter never ran and the test passed against unpatched source for all seven data sets. Write the row before adding the filter. The test now fails on trunk for six of the seven, the exception being the empty array, which `empty()` already caught. --- tests/phpunit/tests/l10n/getLocale.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index 63e79b91991fb..2caf1e56a3e31 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -217,6 +217,10 @@ static function () use ( $value ) { /** * The `option_WPLANG` filter runs after the option is read. * + * get_option() applies it only when the option exists. There is no `WPLANG` + * row in the test database, so the row is written first, otherwise the + * function takes the "no such option" branch and the filter never runs. + * * @group ms-excluded * * @dataProvider data_non_string_locale @@ -228,6 +232,8 @@ public function test_should_fall_back_on_en_US_for_a_non_string_option_filter( $ $old_locale = $locale; $locale = null; + $this->write_raw_option_row( 'WPLANG', 'en_GB' ); + add_filter( 'option_WPLANG', static function () use ( $value ) { From d6b2ae69b3348baba78d107753d5bf7b6cab7378 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 15:14:40 +0400 Subject: [PATCH 05/13] Docs: Trim the comments on the locale guards. Keep the one thing the reader cannot see from the code, where each value comes from, and cut the rest. Drop the `@since` lines. This fixes a fatal error, it does not change an API. --- src/wp-includes/l10n.php | 20 ++++---------------- src/wp-includes/user.php | 1 - 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 42743a71b9f7c..06b42fbd6b27d 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -21,7 +21,6 @@ * always be filtered using the {@see 'locale'} hook. * * @since 1.5.0 - * @since 7.2.0 Non-string and empty values are ignored. * * @global string $locale The current locale. * @global string $wp_local_package Locale code of the package. @@ -32,10 +31,7 @@ function get_locale() { global $locale, $wp_local_package; if ( isset( $locale ) ) { - /* - * The global is set by wp-config.php, by a plugin, or by this function - * on an earlier call, so it carries no type. - */ + // The global may be set by wp-config.php, by a plugin, or by an earlier call to this function. if ( empty( $locale ) || ! is_string( $locale ) ) { $locale = 'en_US'; } @@ -81,10 +77,7 @@ function get_locale() { } } - /* - * The value may have come from an option, a constant or a global, none of - * which guarantee a type. Callers are documented to receive a string. - */ + // This value may come from a global, a constant or an option, none of which are validated. if ( empty( $locale ) || ! is_string( $locale ) ) { $locale = 'en_US'; } @@ -114,7 +107,6 @@ function get_locale() { * returned. Otherwise it returns the locale of get_locale(). * * @since 4.7.0 - * @since 7.2.0 A non-string `locale` user meta value is ignored. * * @param int|WP_User $user User's ID or a WP_User object. Defaults to current user. * @return string The locale of the user. @@ -134,11 +126,7 @@ function get_user_locale( $user = 0 ) { return get_locale(); } - /* - * WP_User has no `locale` property. Reading it runs - * get_user_meta( $user_id, 'locale', true ), so this is a read of untyped - * storage and the row may hold anything, including an array. - */ + // WP_User has no `locale` property: this reads the unvalidated `locale` user meta row. $locale = $user_object->locale; if ( empty( $locale ) || ! is_string( $locale ) ) { @@ -152,7 +140,6 @@ function get_user_locale( $user = 0 ) { * Determines the current locale desired for the request. * * @since 5.0.0 - * @since 7.2.0 Non-string and empty values are ignored. * * @global string $pagenow The filename of the current screen. * @global string $wp_local_package Locale code of the package. @@ -200,6 +187,7 @@ function determine_locale() { } } + // This value may come from the request, from user meta or from a global, and is not validated. if ( empty( $determined_locale ) || ! is_string( $determined_locale ) ) { $determined_locale = get_locale(); } diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 6f54be11c9bd4..7d165879e4062 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2172,7 +2172,6 @@ function validate_username( $username ) { * @since 5.3.0 The `user_activation_key` field can be passed to `$userdata`. * @since 5.3.0 The `spam` field can be passed to `$userdata` (Multisite only). * @since 5.9.0 The `meta_input` field can be passed to `$userdata` to allow addition of user meta data. - * @since 7.2.0 A non-string `locale` field is stored as an empty string. * * @global wpdb $wpdb WordPress database abstraction object. * From 9da8fc80da966a6af5ce29387692e59a1c23b23b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 15:40:04 +0400 Subject: [PATCH 06/13] Users: Drop the wp_insert_user() locale type check. The getters now ignore non-string values, and other write paths such as meta_input and update_user_meta() store them unchanged. Validating one writer changes what wp_insert_user() stores for scalars without closing the gap. --- src/wp-includes/user.php | 3 +- tests/phpunit/tests/user.php | 59 ------------------------------------ 2 files changed, 1 insertion(+), 61 deletions(-) diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 7d165879e4062..6ce8dbf05175d 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -2526,8 +2526,7 @@ function wp_insert_user( $userdata ) { $meta['show_admin_bar_front'] = empty( $userdata['show_admin_bar_front'] ) ? 'true' : $userdata['show_admin_bar_front']; - // The row is read back by get_user_locale(), which is documented to return a string. - $meta['locale'] = isset( $userdata['locale'] ) && is_string( $userdata['locale'] ) ? $userdata['locale'] : ''; + $meta['locale'] = $userdata['locale'] ?? ''; $compacted = compact( 'user_pass', 'user_nicename', 'user_email', 'user_url', 'user_registered', 'user_activation_key', 'display_name' ); $data = wp_unslash( $compacted ); diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index 28ba3198bc065..f600adbcb1164 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -1296,65 +1296,6 @@ public function test_wp_insert_user_should_not_wipe_existing_password() { $this->assertNotEmpty( $user->user_pass ); } - /** - * wp_insert_user() stores `$userdata['locale']` as user meta with no type - * check, so a caller can plant a value that get_user_locale() later returns - * where a string is documented. - * - * @dataProvider data_non_string_locale - * - * @covers ::wp_insert_user - * - * @param mixed $locale Non-string locale. - */ - public function test_wp_insert_user_should_not_store_a_non_string_locale( $locale ) { - $user_id = self::factory()->user->create( array( 'locale' => $locale ) ); - - $this->assertSame( '', get_user_meta( $user_id, 'locale', true ) ); - } - - /** - * wp_update_user() passes the field on to wp_insert_user(). A non-string - * empties the row, the same value the field takes when it is not passed at - * all, rather than storing the non-string. - * - * @dataProvider data_non_string_locale - * - * @covers ::wp_insert_user - * @covers ::wp_update_user - * - * @param mixed $locale Non-string locale. - */ - public function test_wp_update_user_should_not_store_a_non_string_locale( $locale ) { - $user_id = self::factory()->user->create( array( 'locale' => 'de_DE' ) ); - - wp_update_user( - array( - 'ID' => $user_id, - 'locale' => $locale, - ) - ); - - $this->assertSame( '', get_user_meta( $user_id, 'locale', true ) ); - } - - /** - * Data provider. - * - * @return array[] - */ - public function data_non_string_locale() { - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'locale' => 'de_DE' ) ), - 'an empty array' => array( array() ), - 'an object' => array( new stdClass() ), - 'an integer' => array( 1234 ), - 'a float' => array( 1.5 ), - 'true' => array( true ), - ); - } - /** * @ticket 29696 */ From cae7c02c67e72eaf58f1279c9e99d592ec1a70cf Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 19:59:29 +0400 Subject: [PATCH 07/13] Tests: Add return types to the locale getter type tests. Annotate the tests added for the locale getter type checks: void on the test methods and the raw option row helper, array on the data providers, and array shapes in place of array[] in their @return lines. --- tests/phpunit/tests/l10n/determineLocale.php | 26 ++++++++++---------- tests/phpunit/tests/l10n/getLocale.php | 22 ++++++++--------- tests/phpunit/tests/l10n/getUserLocale.php | 10 ++++---- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/phpunit/tests/l10n/determineLocale.php b/tests/phpunit/tests/l10n/determineLocale.php index bbc6dbace4141..21e5141d29cc5 100644 --- a/tests/phpunit/tests/l10n/determineLocale.php +++ b/tests/phpunit/tests/l10n/determineLocale.php @@ -318,7 +318,7 @@ public function test_wp_local_package_global_installing() { * * @param array $value Array request value. */ - public function test_wp_login_get_param_on_login_page_array( $value ) { + public function test_wp_login_get_param_on_login_page_array( $value ): void { $GLOBALS['pagenow'] = 'wp-login.php'; $_GET['wp_lang'] = $value; @@ -330,7 +330,7 @@ public function test_wp_login_get_param_on_login_page_array( $value ) { * * @param array $value Array request value. */ - public function test_wp_login_cookie_on_login_page_array( $value ) { + public function test_wp_login_cookie_on_login_page_array( $value ): void { $GLOBALS['pagenow'] = 'wp-login.php'; $_COOKIE['wp_lang'] = $value; @@ -342,7 +342,7 @@ public function test_wp_login_cookie_on_login_page_array( $value ) { * * @param array $value Array request value. */ - public function test_language_param_installing_array( $value ) { + public function test_language_param_installing_array( $value ): void { $_REQUEST['language'] = $value; wp_installing( true ); @@ -354,7 +354,7 @@ public function test_language_param_installing_array( $value ) { * an array key and throws a TypeError, so translating any string for an * unloaded text domain takes down the login page for an anonymous visitor. */ - public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_registry() { + public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_registry(): void { $GLOBALS['pagenow'] = 'wp-login.php'; $_GET['wp_lang'] = array( 'de_DE' ); @@ -364,9 +364,9 @@ public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_regist /** * Data provider. * - * @return array[] + * @return array}> */ - public function data_array_request_value() { + public function data_array_request_value(): array { // An empty array is falsy, so it never reaches sanitize_locale_name(). return array( 'a list' => array( array( 'de_DE' ) ), @@ -381,7 +381,7 @@ public function data_array_request_value() { * * @param mixed $value Non-string value. */ - public function test_wp_local_package_global_installing_non_string( $value ) { + public function test_wp_local_package_global_installing_non_string( $value ): void { $GLOBALS['wp_local_package'] = $value; wp_installing( true ); $this->assertSame( 'en_US', determine_locale() ); @@ -395,7 +395,7 @@ public function test_wp_local_package_global_installing_non_string( $value ) { * * @param mixed $value Non-string value. */ - public function test_ignores_a_non_string_determine_locale_filter( $value ) { + public function test_ignores_a_non_string_determine_locale_filter( $value ): void { add_filter( 'determine_locale', static function () use ( $value ) { @@ -414,7 +414,7 @@ static function () use ( $value ) { * * @param mixed $meta_value Value stored in the `locale` user meta row. */ - public function test_returns_a_string_for_a_non_string_user_locale_meta( $meta_value ) { + public function test_returns_a_string_for_a_non_string_user_locale_meta( $meta_value ): void { set_current_screen( 'dashboard' ); wp_set_current_user( self::$user_id ); update_user_meta( self::$user_id, 'locale', $meta_value ); @@ -425,9 +425,9 @@ public function test_returns_a_string_for_a_non_string_user_locale_meta( $meta_v /** * Data provider. * - * @return array[] + * @return array */ - public function data_non_string_locale() { + public function data_non_string_locale(): array { return array( 'a list' => array( array( 'de_DE' ) ), 'a map' => array( array( 'locale' => 'de_DE' ) ), @@ -442,9 +442,9 @@ public function data_non_string_locale() { /** * Data provider. * - * @return array[] + * @return array */ - public function data_non_string_user_locale_meta() { + public function data_non_string_user_locale_meta(): array { // Scalars survive the meta round trip as strings, so only arrays and // objects can come back from get_user_meta() with the wrong type. return array( diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index 2caf1e56a3e31..2a00366380070 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -103,7 +103,7 @@ public function filter_get_locale() { * * @param mixed $value Non-string value. */ - public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ) { + public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ): void { global $locale; $old_locale = $locale; $locale = null; @@ -123,7 +123,7 @@ public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ) * * @param mixed $value Non-string value. */ - public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $value ) { + public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $value ): void { global $locale, $wpdb; $old_locale = $locale; $locale = null; @@ -153,7 +153,7 @@ public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $va * * @param mixed $value Non-string value. */ - public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $value ) { + public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $value ): void { global $locale; $old_locale = $locale; $locale = $value; @@ -171,7 +171,7 @@ public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $ * * @param mixed $value Non-string value. */ - public function test_should_ignore_a_non_string_locale_filter( $value ) { + public function test_should_ignore_a_non_string_locale_filter( $value ): void { global $locale; $old_locale = $locale; $locale = null; @@ -196,7 +196,7 @@ static function () use ( $value ) { * * @param mixed $value Non-string value. */ - public function test_should_ignore_a_non_string_locale_filter_on_the_global_path( $value ) { + public function test_should_ignore_a_non_string_locale_filter_on_the_global_path( $value ): void { global $locale; $old_locale = $locale; $locale = 'es_ES'; @@ -227,7 +227,7 @@ static function () use ( $value ) { * * @param mixed $value Non-string value. */ - public function test_should_fall_back_on_en_US_for_a_non_string_option_filter( $value ) { + public function test_should_fall_back_on_en_US_for_a_non_string_option_filter( $value ): void { global $locale; $old_locale = $locale; $locale = null; @@ -254,7 +254,7 @@ static function () use ( $value ) { * @param string $option Option name. * @param mixed $value Value to store. */ - private function write_raw_option_row( $option, $value ) { + private function write_raw_option_row( $option, $value ): void { global $wpdb; $wpdb->replace( @@ -271,9 +271,9 @@ private function write_raw_option_row( $option, $value ) { /** * Data provider. * - * @return array[] + * @return array */ - public function data_non_string_locale() { + public function data_non_string_locale(): array { return array( 'a list' => array( array( 'de_DE' ) ), 'a map' => array( array( 'locale' => 'de_DE' ) ), @@ -288,9 +288,9 @@ public function data_non_string_locale() { /** * Data provider. * - * @return array[] + * @return array */ - public function data_stored_non_string_locale() { + public function data_stored_non_string_locale(): array { // Scalars survive the storage round trip as strings, so only arrays and // objects can come back from an option with the wrong type. return array( diff --git a/tests/phpunit/tests/l10n/getUserLocale.php b/tests/phpunit/tests/l10n/getUserLocale.php index 6c494a3bbac22..ceaac389c05e0 100644 --- a/tests/phpunit/tests/l10n/getUserLocale.php +++ b/tests/phpunit/tests/l10n/getUserLocale.php @@ -156,7 +156,7 @@ public function test_user_id_argument_with_invalid_type() { * * @param mixed $meta_value Value stored in the `locale` user meta row. */ - public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_value ) { + public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_value ): void { set_current_screen( 'dashboard' ); update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); @@ -168,7 +168,7 @@ public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_ * * @param mixed $meta_value Value stored in the `locale` user meta row. */ - public function test_returns_a_string_for_non_string_user_locale_meta( $meta_value ) { + public function test_returns_a_string_for_non_string_user_locale_meta( $meta_value ): void { set_current_screen( 'dashboard' ); update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); @@ -178,9 +178,9 @@ public function test_returns_a_string_for_non_string_user_locale_meta( $meta_val /** * Data provider. * - * @return array[] + * @return array */ - public function data_non_string_user_locale_meta() { + public function data_non_string_user_locale_meta(): array { // Scalars survive the meta round trip as strings, so only arrays and // objects can come back from get_user_meta() with the wrong type. return array( @@ -196,7 +196,7 @@ public function data_non_string_user_locale_meta() { * array key and throws a TypeError, so translating any string for an * unloaded text domain takes down the request. */ - public function test_array_user_locale_meta_does_not_fatal_in_the_textdomain_registry() { + public function test_array_user_locale_meta_does_not_fatal_in_the_textdomain_registry(): void { set_current_screen( 'dashboard' ); update_user_meta( self::$administrator_de_de, 'locale', array( 'de_DE' ) ); From ce9a3177cd511f4746058de63989ae79f9df3718 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 20:04:14 +0400 Subject: [PATCH 08/13] Tests: Reference the ticket from the locale getter type tests. Add @ticket 66106 to the 16 test methods this change adds. The data providers and the raw option row helper carry no ticket reference. --- tests/phpunit/tests/l10n/determineLocale.php | 14 ++++++++++++++ tests/phpunit/tests/l10n/getLocale.php | 12 ++++++++++++ tests/phpunit/tests/l10n/getUserLocale.php | 6 ++++++ 3 files changed, 32 insertions(+) diff --git a/tests/phpunit/tests/l10n/determineLocale.php b/tests/phpunit/tests/l10n/determineLocale.php index 21e5141d29cc5..6388ed0b8da53 100644 --- a/tests/phpunit/tests/l10n/determineLocale.php +++ b/tests/phpunit/tests/l10n/determineLocale.php @@ -314,6 +314,8 @@ public function test_wp_local_package_global_installing() { * subject and returns an array, so `wp-login.php?wp_lang[]=de_DE` reaches * the return statement with an array. No authentication is needed. * + * @ticket 66106 + * * @dataProvider data_array_request_value * * @param array $value Array request value. @@ -326,6 +328,8 @@ public function test_wp_login_get_param_on_login_page_array( $value ): void { } /** + * @ticket 66106 + * * @dataProvider data_array_request_value * * @param array $value Array request value. @@ -338,6 +342,8 @@ public function test_wp_login_cookie_on_login_page_array( $value ): void { } /** + * @ticket 66106 + * * @dataProvider data_array_request_value * * @param array $value Array request value. @@ -353,6 +359,8 @@ public function test_language_param_installing_array( $value ): void { * An array locale reaches WP_Textdomain_Registry::set(), which uses it as * an array key and throws a TypeError, so translating any string for an * unloaded text domain takes down the login page for an anonymous visitor. + * + * @ticket 66106 */ public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_registry(): void { $GLOBALS['pagenow'] = 'wp-login.php'; @@ -377,6 +385,8 @@ public function data_array_request_value(): array { /** * The `$wp_local_package` global is untyped and only checked for truthiness. * + * @ticket 66106 + * * @dataProvider data_non_string_locale * * @param mixed $value Non-string value. @@ -391,6 +401,8 @@ public function test_wp_local_package_global_installing_non_string( $value ): vo * The `determine_locale` filter result is returned unchecked, unlike * `pre_determine_locale`, which is guarded with is_string(). * + * @ticket 66106 + * * @dataProvider data_non_string_locale * * @param mixed $value Non-string value. @@ -410,6 +422,8 @@ static function () use ( $value ) { * An array `locale` user meta row reaches determine_locale() through * get_user_locale() on every admin request. * + * @ticket 66106 + * * @dataProvider data_non_string_user_locale_meta * * @param mixed $meta_value Value stored in the `locale` user meta row. diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index 2a00366380070..352ec80bc467f 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -97,6 +97,8 @@ public function filter_get_locale() { * written by a direct database query survives to the return value: a * non-empty array passes both `false !== $db_locale` and `empty( $locale )`. * + * @ticket 66106 + * * @group ms-excluded * * @dataProvider data_stored_non_string_locale @@ -117,6 +119,8 @@ public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ) } /** + * @ticket 66106 + * * @group ms-required * * @dataProvider data_stored_non_string_locale @@ -149,6 +153,8 @@ public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $va * The `$locale` global short-circuits the function before any of its own * guards run. * + * @ticket 66106 + * * @dataProvider data_non_string_locale * * @param mixed $value Non-string value. @@ -167,6 +173,8 @@ public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $ /** * The `locale` filter result is returned unchecked. * + * @ticket 66106 + * * @dataProvider data_non_string_locale * * @param mixed $value Non-string value. @@ -192,6 +200,8 @@ static function () use ( $value ) { /** * The `locale` filter also runs on the `$locale` global short-circuit path. * + * @ticket 66106 + * * @dataProvider data_non_string_locale * * @param mixed $value Non-string value. @@ -221,6 +231,8 @@ static function () use ( $value ) { * row in the test database, so the row is written first, otherwise the * function takes the "no such option" branch and the filter never runs. * + * @ticket 66106 + * * @group ms-excluded * * @dataProvider data_non_string_locale diff --git a/tests/phpunit/tests/l10n/getUserLocale.php b/tests/phpunit/tests/l10n/getUserLocale.php index ceaac389c05e0..2df34dce40fda 100644 --- a/tests/phpunit/tests/l10n/getUserLocale.php +++ b/tests/phpunit/tests/l10n/getUserLocale.php @@ -152,6 +152,8 @@ public function test_user_id_argument_with_invalid_type() { * guard passes it through and callers receive an array where the documented * return type is a string. * + * @ticket 66106 + * * @dataProvider data_non_string_user_locale_meta * * @param mixed $meta_value Value stored in the `locale` user meta row. @@ -164,6 +166,8 @@ public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_ } /** + * @ticket 66106 + * * @dataProvider data_non_string_user_locale_meta * * @param mixed $meta_value Value stored in the `locale` user meta row. @@ -195,6 +199,8 @@ public function data_non_string_user_locale_meta(): array { * An array locale reaches WP_Textdomain_Registry::set(), which uses it as an * array key and throws a TypeError, so translating any string for an * unloaded text domain takes down the request. + * + * @ticket 66106 */ public function test_array_user_locale_meta_does_not_fatal_in_the_textdomain_registry(): void { set_current_screen( 'dashboard' ); From 50ce9525a7c1d965591cc7aec00cb47b3b8a7701 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 20:06:58 +0400 Subject: [PATCH 09/13] Tests: Describe the locale type failure without the request recipe. State what the tests cover in terms of types and the registry, without spelling out a request that ends in a fatal error on a released site. --- tests/phpunit/tests/l10n/determineLocale.php | 6 +++--- tests/phpunit/tests/l10n/getUserLocale.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/l10n/determineLocale.php b/tests/phpunit/tests/l10n/determineLocale.php index 6388ed0b8da53..d77655348c5c8 100644 --- a/tests/phpunit/tests/l10n/determineLocale.php +++ b/tests/phpunit/tests/l10n/determineLocale.php @@ -311,8 +311,8 @@ public function test_wp_local_package_global_installing() { /** * sanitize_locale_name() applies preg_replace(), which maps over an array - * subject and returns an array, so `wp-login.php?wp_lang[]=de_DE` reaches - * the return statement with an array. No authentication is needed. + * subject and returns an array, so a non-string request value reaches the + * return statement with its type intact. * * @ticket 66106 * @@ -358,7 +358,7 @@ public function test_language_param_installing_array( $value ): void { /** * An array locale reaches WP_Textdomain_Registry::set(), which uses it as * an array key and throws a TypeError, so translating any string for an - * unloaded text domain takes down the login page for an anonymous visitor. + * unloaded text domain ends the request. * * @ticket 66106 */ diff --git a/tests/phpunit/tests/l10n/getUserLocale.php b/tests/phpunit/tests/l10n/getUserLocale.php index 2df34dce40fda..e4fd669ad9b32 100644 --- a/tests/phpunit/tests/l10n/getUserLocale.php +++ b/tests/phpunit/tests/l10n/getUserLocale.php @@ -198,7 +198,7 @@ public function data_non_string_user_locale_meta(): array { /** * An array locale reaches WP_Textdomain_Registry::set(), which uses it as an * array key and throws a TypeError, so translating any string for an - * unloaded text domain takes down the request. + * unloaded text domain ends the request. * * @ticket 66106 */ From 5295341469660f984972d37a837973e8c144a663 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 20:14:45 +0400 Subject: [PATCH 10/13] I18N: Return an empty string from sanitize_locale_name() for a non-string. determine_locale() passed request values to sanitize_locale_name() before its own type check. preg_replace() maps over an array, so the `sanitize_locale_name` filter received arrays in both arguments that it documents as strings. --- src/wp-includes/formatting.php | 5 +++++ src/wp-includes/l10n.php | 2 +- tests/phpunit/tests/formatting/sanitizeLocaleName.php | 9 +++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index abbf18c3b74d2..a341f7f335676 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2466,6 +2466,11 @@ function sanitize_html_class( $classname, $fallback = '' ) { * @return string The sanitized value. */ function sanitize_locale_name( $locale_name ) { + // Request values can arrive as arrays, and preg_replace() would map over them. + if ( ! is_string( $locale_name ) ) { + return ''; + } + // Limit to A-Z, a-z, 0-9, '_', '-'. $sanitized = preg_replace( '/[^A-Za-z0-9_-]/', '', $locale_name ); diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 06b42fbd6b27d..0d97ee064ae63 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -187,7 +187,7 @@ function determine_locale() { } } - // This value may come from the request, from user meta or from a global, and is not validated. + // This value may come from user meta or from a global, and is not validated. if ( empty( $determined_locale ) || ! is_string( $determined_locale ) ) { $determined_locale = get_locale(); } diff --git a/tests/phpunit/tests/formatting/sanitizeLocaleName.php b/tests/phpunit/tests/formatting/sanitizeLocaleName.php index cd22acbf2c60a..614526c91435d 100644 --- a/tests/phpunit/tests/formatting/sanitizeLocaleName.php +++ b/tests/phpunit/tests/formatting/sanitizeLocaleName.php @@ -46,4 +46,13 @@ public function data_sanitize_locale_name_returns_empty_string() { array( '@///' ), ); } + + /** + * Request parameters can arrive as arrays. + * + * @ticket 66106 + */ + public function test_sanitize_locale_name_returns_empty_string_for_a_non_string() { + $this->assertSame( '', sanitize_locale_name( array( 'de_DE' ) ) ); + } } From d72d21d0b8876c29b24a5942212a1a29614e8114 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 14 Sep 2026 20:14:45 +0400 Subject: [PATCH 11/13] Tests: Reduce the locale getter type tests to the essential cases. One test per guarded source: the option, the `$locale` global, the `locale` filter, user meta, the `wp_lang` request parameter and the `determine_locale` filter, plus the translation call that threw the TypeError. Drop the data providers and the duplicate string-type assertion. --- tests/phpunit/tests/l10n/determineLocale.php | 135 +------------- tests/phpunit/tests/l10n/getLocale.php | 186 ++----------------- tests/phpunit/tests/l10n/getUserLocale.php | 57 +----- 3 files changed, 19 insertions(+), 359 deletions(-) diff --git a/tests/phpunit/tests/l10n/determineLocale.php b/tests/phpunit/tests/l10n/determineLocale.php index d77655348c5c8..37f638cf0d5dc 100644 --- a/tests/phpunit/tests/l10n/determineLocale.php +++ b/tests/phpunit/tests/l10n/determineLocale.php @@ -309,48 +309,12 @@ public function test_wp_local_package_global_installing() { $this->assertSame( 'de_DE', determine_locale() ); } - /** - * sanitize_locale_name() applies preg_replace(), which maps over an array - * subject and returns an array, so a non-string request value reaches the - * return statement with its type intact. - * - * @ticket 66106 - * - * @dataProvider data_array_request_value - * - * @param array $value Array request value. - */ - public function test_wp_login_get_param_on_login_page_array( $value ): void { - $GLOBALS['pagenow'] = 'wp-login.php'; - $_GET['wp_lang'] = $value; - - $this->assertSame( 'en_US', determine_locale() ); - } - /** * @ticket 66106 - * - * @dataProvider data_array_request_value - * - * @param array $value Array request value. */ - public function test_wp_login_cookie_on_login_page_array( $value ): void { + public function test_wp_login_get_param_on_login_page_array(): void { $GLOBALS['pagenow'] = 'wp-login.php'; - $_COOKIE['wp_lang'] = $value; - - $this->assertSame( 'en_US', determine_locale() ); - } - - /** - * @ticket 66106 - * - * @dataProvider data_array_request_value - * - * @param array $value Array request value. - */ - public function test_language_param_installing_array( $value ): void { - $_REQUEST['language'] = $value; - wp_installing( true ); + $_GET['wp_lang'] = array( 'de_DE' ); $this->assertSame( 'en_US', determine_locale() ); } @@ -370,102 +334,11 @@ public function test_array_wp_lang_param_does_not_fatal_in_the_textdomain_regist } /** - * Data provider. - * - * @return array}> - */ - public function data_array_request_value(): array { - // An empty array is falsy, so it never reaches sanitize_locale_name(). - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'lang' => 'de_DE' ) ), - ); - } - - /** - * The `$wp_local_package` global is untyped and only checked for truthiness. - * - * @ticket 66106 - * - * @dataProvider data_non_string_locale - * - * @param mixed $value Non-string value. - */ - public function test_wp_local_package_global_installing_non_string( $value ): void { - $GLOBALS['wp_local_package'] = $value; - wp_installing( true ); - $this->assertSame( 'en_US', determine_locale() ); - } - - /** - * The `determine_locale` filter result is returned unchecked, unlike - * `pre_determine_locale`, which is guarded with is_string(). - * * @ticket 66106 - * - * @dataProvider data_non_string_locale - * - * @param mixed $value Non-string value. */ - public function test_ignores_a_non_string_determine_locale_filter( $value ): void { - add_filter( - 'determine_locale', - static function () use ( $value ) { - return $value; - } - ); + public function test_ignores_a_non_string_determine_locale_filter(): void { + add_filter( 'determine_locale', '__return_empty_array' ); $this->assertSame( 'en_US', determine_locale() ); } - - /** - * An array `locale` user meta row reaches determine_locale() through - * get_user_locale() on every admin request. - * - * @ticket 66106 - * - * @dataProvider data_non_string_user_locale_meta - * - * @param mixed $meta_value Value stored in the `locale` user meta row. - */ - public function test_returns_a_string_for_a_non_string_user_locale_meta( $meta_value ): void { - set_current_screen( 'dashboard' ); - wp_set_current_user( self::$user_id ); - update_user_meta( self::$user_id, 'locale', $meta_value ); - - $this->assertSame( 'en_US', determine_locale() ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_non_string_locale(): array { - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'locale' => 'de_DE' ) ), - 'an empty array' => array( array() ), - 'an object' => array( new stdClass() ), - 'an integer' => array( 1234 ), - 'a float' => array( 1.5 ), - 'true' => array( true ), - ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_non_string_user_locale_meta(): array { - // Scalars survive the meta round trip as strings, so only arrays and - // objects can come back from get_user_meta() with the wrong type. - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'locale' => 'de_DE' ) ), - 'an empty array' => array( array() ), - 'an object' => array( new stdClass() ), - ); - } } diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index 352ec80bc467f..1ba5a31c9b504 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -92,53 +92,23 @@ public function filter_get_locale() { } /** - * The `WPLANG` option is untyped storage. sanitize_option() rejects a - * non-string on the way in, but nothing checks it on the way out, so a row - * written by a direct database query survives to the return value: a - * non-empty array passes both `false !== $db_locale` and `empty( $locale )`. + * Nothing checks the type of the `WPLANG` option on the way out, so a row + * written by a direct database query or a migration reaches the return value. * * @ticket 66106 * * @group ms-excluded - * - * @dataProvider data_stored_non_string_locale - * - * @param mixed $value Non-string value. - */ - public function test_should_fall_back_on_en_US_for_a_non_string_option( $value ): void { - global $locale; - $old_locale = $locale; - $locale = null; - - $this->write_raw_option_row( 'WPLANG', $value ); - - $found = get_locale(); - $locale = $old_locale; - - $this->assertSame( 'en_US', $found ); - } - - /** - * @ticket 66106 - * - * @group ms-required - * - * @dataProvider data_stored_non_string_locale - * - * @param mixed $value Non-string value. */ - public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $value ): void { + public function test_should_fall_back_on_en_US_for_a_non_string_option(): void { global $locale, $wpdb; $old_locale = $locale; $locale = null; - update_site_option( 'WPLANG', 'en_US' ); - $wpdb->update( - $wpdb->sitemeta, - array( 'meta_value' => maybe_serialize( $value ) ), + $wpdb->replace( + $wpdb->options, array( - 'site_id' => get_current_network_id(), - 'meta_key' => 'WPLANG', + 'option_name' => 'WPLANG', + 'option_value' => maybe_serialize( array( 'de_DE' ) ), ) ); wp_cache_flush(); @@ -150,19 +120,12 @@ public function test_should_fall_back_on_en_US_for_a_non_string_site_option( $va } /** - * The `$locale` global short-circuits the function before any of its own - * guards run. - * * @ticket 66106 - * - * @dataProvider data_non_string_locale - * - * @param mixed $value Non-string value. */ - public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $value ): void { + public function test_should_fall_back_on_en_US_for_a_non_string_locale_global(): void { global $locale; $old_locale = $locale; - $locale = $value; + $locale = array( 'de_DE' ); $found = get_locale(); $locale = $old_locale; @@ -171,145 +134,18 @@ public function test_should_fall_back_on_en_US_for_a_non_string_locale_global( $ } /** - * The `locale` filter result is returned unchecked. - * * @ticket 66106 - * - * @dataProvider data_non_string_locale - * - * @param mixed $value Non-string value. */ - public function test_should_ignore_a_non_string_locale_filter( $value ): void { - global $locale; - $old_locale = $locale; - $locale = null; - - add_filter( - 'locale', - static function () use ( $value ) { - return $value; - } - ); - - $found = get_locale(); - $locale = $old_locale; - - $this->assertSame( 'en_US', $found ); - } - - /** - * The `locale` filter also runs on the `$locale` global short-circuit path. - * - * @ticket 66106 - * - * @dataProvider data_non_string_locale - * - * @param mixed $value Non-string value. - */ - public function test_should_ignore_a_non_string_locale_filter_on_the_global_path( $value ): void { + public function test_should_ignore_a_non_string_locale_filter(): void { global $locale; $old_locale = $locale; $locale = 'es_ES'; - add_filter( - 'locale', - static function () use ( $value ) { - return $value; - } - ); + add_filter( 'locale', '__return_empty_array' ); $found = get_locale(); $locale = $old_locale; $this->assertSame( 'es_ES', $found ); } - - /** - * The `option_WPLANG` filter runs after the option is read. - * - * get_option() applies it only when the option exists. There is no `WPLANG` - * row in the test database, so the row is written first, otherwise the - * function takes the "no such option" branch and the filter never runs. - * - * @ticket 66106 - * - * @group ms-excluded - * - * @dataProvider data_non_string_locale - * - * @param mixed $value Non-string value. - */ - public function test_should_fall_back_on_en_US_for_a_non_string_option_filter( $value ): void { - global $locale; - $old_locale = $locale; - $locale = null; - - $this->write_raw_option_row( 'WPLANG', 'en_GB' ); - - add_filter( - 'option_WPLANG', - static function () use ( $value ) { - return $value; - } - ); - - $found = get_locale(); - $locale = $old_locale; - - $this->assertSame( 'en_US', $found ); - } - - /** - * Writes an option row without going through sanitize_option(), the way a - * migration or a direct database query would. - * - * @param string $option Option name. - * @param mixed $value Value to store. - */ - private function write_raw_option_row( $option, $value ): void { - global $wpdb; - - $wpdb->replace( - $wpdb->options, - array( - 'option_name' => $option, - 'option_value' => maybe_serialize( $value ), - ) - ); - - wp_cache_flush(); - } - - /** - * Data provider. - * - * @return array - */ - public function data_non_string_locale(): array { - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'locale' => 'de_DE' ) ), - 'an empty array' => array( array() ), - 'an object' => array( new stdClass() ), - 'an integer' => array( 1234 ), - 'a float' => array( 1.5 ), - 'true' => array( true ), - ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_stored_non_string_locale(): array { - // Scalars survive the storage round trip as strings, so only arrays and - // objects can come back from an option with the wrong type. - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'locale' => 'de_DE' ) ), - 'an empty array' => array( array() ), - 'an object' => array( new stdClass() ), - ); - } } diff --git a/tests/phpunit/tests/l10n/getUserLocale.php b/tests/phpunit/tests/l10n/getUserLocale.php index e4fd669ad9b32..f79864dd25e79 100644 --- a/tests/phpunit/tests/l10n/getUserLocale.php +++ b/tests/phpunit/tests/l10n/getUserLocale.php @@ -148,64 +148,15 @@ public function test_user_id_argument_with_invalid_type() { } /** - * A `locale` user meta row holding an array is truthy, so the `empty()`-style - * guard passes it through and callers receive an array where the documented - * return type is a string. + * A `locale` user meta row holding an array is truthy, so a truthiness + * check alone passes it through to callers that expect a string. * * @ticket 66106 - * - * @dataProvider data_non_string_user_locale_meta - * - * @param mixed $meta_value Value stored in the `locale` user meta row. - */ - public function test_returns_site_locale_for_non_string_user_locale_meta( $meta_value ): void { - set_current_screen( 'dashboard' ); - update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); - - $this->assertSame( get_locale(), get_user_locale() ); - } - - /** - * @ticket 66106 - * - * @dataProvider data_non_string_user_locale_meta - * - * @param mixed $meta_value Value stored in the `locale` user meta row. */ - public function test_returns_a_string_for_non_string_user_locale_meta( $meta_value ): void { - set_current_screen( 'dashboard' ); - update_user_meta( self::$administrator_de_de, 'locale', $meta_value ); - - $this->assertIsString( get_user_locale() ); - } - - /** - * Data provider. - * - * @return array - */ - public function data_non_string_user_locale_meta(): array { - // Scalars survive the meta round trip as strings, so only arrays and - // objects can come back from get_user_meta() with the wrong type. - return array( - 'a list' => array( array( 'de_DE' ) ), - 'a map' => array( array( 'locale' => 'de_DE' ) ), - 'an empty array' => array( array() ), - 'an object' => array( new stdClass() ), - ); - } - - /** - * An array locale reaches WP_Textdomain_Registry::set(), which uses it as an - * array key and throws a TypeError, so translating any string for an - * unloaded text domain ends the request. - * - * @ticket 66106 - */ - public function test_array_user_locale_meta_does_not_fatal_in_the_textdomain_registry(): void { + public function test_returns_site_locale_for_non_string_user_locale_meta(): void { set_current_screen( 'dashboard' ); update_user_meta( self::$administrator_de_de, 'locale', array( 'de_DE' ) ); - $this->assertSame( 'Some text', __( 'Some text', 'my-plugin' ) ); + $this->assertSame( get_locale(), get_user_locale() ); } } From dd3a017dad085d9f7b4b3511b0e6347a943d14b5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 16 Sep 2026 11:16:41 +0400 Subject: [PATCH 12/13] Tests: Restore the locale global in tear_down() in the get_locale() tests. Each test saved the `$locale` global and restored it between the call and the assertion, so a test that failed part way through left the global set for the rest of the process. Save it in set_up() and restore it in tear_down() instead, and document the globals each test method uses. --- tests/phpunit/tests/l10n/getLocale.php | 118 +++++++++++++++---------- 1 file changed, 73 insertions(+), 45 deletions(-) diff --git a/tests/phpunit/tests/l10n/getLocale.php b/tests/phpunit/tests/l10n/getLocale.php index 1ba5a31c9b504..47a36b2420e11 100644 --- a/tests/phpunit/tests/l10n/getLocale.php +++ b/tests/phpunit/tests/l10n/getLocale.php @@ -7,76 +7,106 @@ * @covers ::get_locale */ class Tests_L10n_GetLocale extends WP_UnitTestCase { + + /** + * The value of the `$locale` global before the current test ran. + * + * @var string|null + */ + private ?string $original_locale = null; + + /** + * Saves the locale global, which these tests overwrite. + * + * @global string $locale The current locale. + */ + public function set_up(): void { + parent::set_up(); + + global $locale; + + $this->original_locale = $locale; + } + + /** + * Restores the locale global, including after a test fails part way through. + * + * @global string $locale The current locale. + */ + public function tear_down(): void { + global $locale; + + $locale = $this->original_locale; + + parent::tear_down(); + } + + /** + * @global string $locale The current locale. + */ public function test_should_respect_locale_global() { global $locale; - $old_locale = $locale; $locale = 'foo'; - $found = get_locale(); - $locale = $old_locale; - - $this->assertSame( 'foo', $found ); + $this->assertSame( 'foo', get_locale() ); } /** * @group ms-required + * + * @global string $locale The current locale. */ public function test_local_option_should_take_precedence_on_multisite() { global $locale; - $old_locale = $locale; - $locale = null; + + $locale = null; update_option( 'WPLANG', 'en_GB' ); update_site_option( 'WPLANG', 'es_ES' ); - $found = get_locale(); - $locale = $old_locale; - - $this->assertSame( 'en_GB', $found ); + $this->assertSame( 'en_GB', get_locale() ); } /** * @group ms-required + * + * @global string $locale The current locale. */ public function test_network_option_should_be_fallback_on_multisite() { global $locale; - $old_locale = $locale; - $locale = null; - update_site_option( 'WPLANG', 'es_ES' ); + $locale = null; - $found = get_locale(); - $locale = $old_locale; + update_site_option( 'WPLANG', 'es_ES' ); - $this->assertSame( 'es_ES', $found ); + $this->assertSame( 'es_ES', get_locale() ); } /** * @group ms-excluded + * + * @global string $locale The current locale. */ public function test_option_should_be_respected_on_nonmultisite() { global $locale; - $old_locale = $locale; - $locale = null; - update_option( 'WPLANG', 'es_ES' ); + $locale = null; - $found = get_locale(); - $locale = $old_locale; + update_option( 'WPLANG', 'es_ES' ); - $this->assertSame( 'es_ES', $found ); + $this->assertSame( 'es_ES', get_locale() ); } + /** + * @global string $locale The current locale. + */ public function test_should_fall_back_on_en_US() { global $locale; - $old_locale = $locale; - $locale = null; - $found = get_locale(); - $locale = $old_locale; + $locale = null; - $this->assertSame( 'en_US', $found ); + $this->assertSame( 'en_US', get_locale() ); } public function test_should_respect_get_locale_filter() { @@ -98,11 +128,14 @@ public function filter_get_locale() { * @ticket 66106 * * @group ms-excluded + * + * @global wpdb $wpdb WordPress database abstraction object. + * @global string $locale The current locale. */ public function test_should_fall_back_on_en_US_for_a_non_string_option(): void { global $locale, $wpdb; - $old_locale = $locale; - $locale = null; + + $locale = null; $wpdb->replace( $wpdb->options, @@ -113,39 +146,34 @@ public function test_should_fall_back_on_en_US_for_a_non_string_option(): void { ); wp_cache_flush(); - $found = get_locale(); - $locale = $old_locale; - - $this->assertSame( 'en_US', $found ); + $this->assertSame( 'en_US', get_locale() ); } /** * @ticket 66106 + * + * @global string $locale The current locale. */ public function test_should_fall_back_on_en_US_for_a_non_string_locale_global(): void { global $locale; - $old_locale = $locale; - $locale = array( 'de_DE' ); - $found = get_locale(); - $locale = $old_locale; + $locale = array( 'de_DE' ); - $this->assertSame( 'en_US', $found ); + $this->assertSame( 'en_US', get_locale() ); } /** * @ticket 66106 + * + * @global string $locale The current locale. */ public function test_should_ignore_a_non_string_locale_filter(): void { global $locale; - $old_locale = $locale; - $locale = 'es_ES'; - add_filter( 'locale', '__return_empty_array' ); + $locale = 'es_ES'; - $found = get_locale(); - $locale = $old_locale; + add_filter( 'locale', '__return_empty_array' ); - $this->assertSame( 'es_ES', $found ); + $this->assertSame( 'es_ES', get_locale() ); } } From 902cb20f8c28c29a59b55a0d27441b99845b3eb3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Wed, 16 Sep 2026 11:16:41 +0400 Subject: [PATCH 13/13] Tests: Type the non-string sanitize_locale_name() test and mark the argument. Add the `void` return type the other locale type tests carry, and mark the intentional array argument for PHPStan. --- tests/phpunit/tests/formatting/sanitizeLocaleName.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/formatting/sanitizeLocaleName.php b/tests/phpunit/tests/formatting/sanitizeLocaleName.php index 614526c91435d..844584cb65606 100644 --- a/tests/phpunit/tests/formatting/sanitizeLocaleName.php +++ b/tests/phpunit/tests/formatting/sanitizeLocaleName.php @@ -52,7 +52,7 @@ public function data_sanitize_locale_name_returns_empty_string() { * * @ticket 66106 */ - public function test_sanitize_locale_name_returns_empty_string_for_a_non_string() { - $this->assertSame( '', sanitize_locale_name( array( 'de_DE' ) ) ); + public function test_sanitize_locale_name_returns_empty_string_for_a_non_string(): void { + $this->assertSame( '', sanitize_locale_name( array( 'de_DE' ) ) ); // @phpstan-ignore argument.type (Passing an array is intentional here.) } }