From 1e238ce1eea244535837d2805867777f990bee85 Mon Sep 17 00:00:00 2001
From: westonruter
Date: Sat, 4 Jul 2026 16:52:08 +0000
Subject: [PATCH 1/7] Robots: Use `admin_url()` for the `robots.txt` admin
paths.
Derive the `Disallow` and `Allow` directives in `do_robots()` from `admin_url()` and `admin_url( 'admin-ajax.php' )` rather than from a hardcoded `/wp-admin/` path built off `site_url()`. Only the URL path portion is emitted, as before, so installs that relocate or filter their admin URL now produce a correct default `robots.txt`.
Also guard the `Content-Type` header with a `headers_sent()` check to avoid a warning when the headers have already been sent.
Developed in https://github.com/WordPress/wordpress-develop/pull/11998.
Follow-up to r34985.
Props masteradhoc, yogeshbhutkar, hrohh, westonruter, mukeshpanchal27, 1ucay.
Fixes #63467.
git-svn-id: https://develop.svn.wordpress.org/trunk@62633 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/functions.php | 10 +++++-----
tests/phpunit/tests/robots.php | 20 ++++++++++++++++++++
2 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php
index b576896d9904b..d329b3463bb50 100644
--- a/src/wp-includes/functions.php
+++ b/src/wp-includes/functions.php
@@ -1711,7 +1711,9 @@ function do_feed_atom( $for_comments ) {
* filter callback.
*/
function do_robots() {
- header( 'Content-Type: text/plain; charset=utf-8' );
+ if ( ! headers_sent() ) {
+ header( 'Content-Type: text/plain; charset=utf-8' );
+ }
/**
* Fires when displaying the robots.txt file.
@@ -1723,10 +1725,8 @@ function do_robots() {
$output = "User-agent: *\n";
$public = (bool) get_option( 'blog_public' );
- $site_url = parse_url( site_url() );
- $path = ( ! empty( $site_url['path'] ) ) ? $site_url['path'] : '';
- $output .= "Disallow: $path/wp-admin/\n";
- $output .= "Allow: $path/wp-admin/admin-ajax.php\n";
+ $output .= 'Disallow: ' . wp_parse_url( admin_url(), PHP_URL_PATH ) . "\n";
+ $output .= 'Allow: ' . wp_parse_url( admin_url( 'admin-ajax.php' ), PHP_URL_PATH ) . "\n";
/**
* Filters the robots.txt output.
diff --git a/tests/phpunit/tests/robots.php b/tests/phpunit/tests/robots.php
index 356224e79b51d..6baaecfd44244 100644
--- a/tests/phpunit/tests/robots.php
+++ b/tests/phpunit/tests/robots.php
@@ -144,6 +144,26 @@ public function test_wp_robots_non_search_page() {
$this->assertStringNotContainsString( 'noindex', $output );
}
+ /**
+ * @ticket 63467
+ */
+ public function test_do_robots_uses_filtered_admin_url_paths(): void {
+ add_filter(
+ 'admin_url',
+ static function ( string $url, string $path, ?int $blog_id, string $scheme ): string {
+ return home_url( "/control/$path", $scheme );
+ },
+ 10,
+ 4
+ );
+
+ $output = get_echo( 'do_robots' );
+
+ $this->assertStringNotContainsString( 'wp-admin', $output );
+ $this->assertStringContainsString( "Disallow: /control/\n", $output );
+ $this->assertStringContainsString( "Allow: /control/admin-ajax.php\n", $output );
+ }
+
public function add_noindex_directive( array $robots ) {
$robots['noindex'] = true;
return $robots;
From ec8b004757172b71105483afebde0192f32148b6 Mon Sep 17 00:00:00 2001
From: SergeyBiryukov
Date: Sat, 4 Jul 2026 23:32:48 +0000
Subject: [PATCH 2/7] Site Health: Remove redundant `function_exists( 'ini_get'
)` checks.
The `ini_get()` function is called unconditionally early in the bootstrap process, so there is no need for subsequent `function_exists( 'ini_get' )` checks.
Follow-up to [44986], [45104].
Props siliconforks, shreyasikhar26, ankitkumarshah, westonruter, SergeyBiryukov.
Fixes #65423.
git-svn-id: https://develop.svn.wordpress.org/trunk@62634 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-admin/includes/class-wp-debug-data.php | 151 +++++++-----------
.../includes/class-wp-site-health.php | 10 --
src/wp-includes/functions.php | 7 +-
3 files changed, 63 insertions(+), 105 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-debug-data.php b/src/wp-admin/includes/class-wp-debug-data.php
index f2399b30550c9..aada2f88dfaa6 100644
--- a/src/wp-admin/includes/class-wp-debug-data.php
+++ b/src/wp-admin/includes/class-wp-debug-data.php
@@ -387,57 +387,44 @@ private static function get_wp_server(): array {
'debug' => PHP_SAPI,
);
- // Some servers disable `ini_set()` and `ini_get()`, we check this before trying to get configuration values.
- if ( ! function_exists( 'ini_get' ) ) {
- $fields['ini_get'] = array(
- 'label' => __( 'Server settings' ),
- 'value' => sprintf(
- /* translators: %s: ini_get() */
- __( 'Unable to determine some settings, as the %s function has been disabled.' ),
- 'ini_get()'
- ),
- 'debug' => 'ini_get() is disabled',
- );
- } else {
- $fields['max_input_variables'] = array(
- 'label' => __( 'PHP max input variables' ),
- 'value' => ini_get( 'max_input_vars' ),
- );
- $fields['time_limit'] = array(
- 'label' => __( 'PHP time limit' ),
- 'value' => ini_get( 'max_execution_time' ),
- );
-
- if ( WP_Site_Health::get_instance()->php_memory_limit !== ini_get( 'memory_limit' ) ) {
- $fields['memory_limit'] = array(
- 'label' => __( 'PHP memory limit' ),
- 'value' => WP_Site_Health::get_instance()->php_memory_limit,
- );
- $fields['admin_memory_limit'] = array(
- 'label' => __( 'PHP memory limit (only for admin screens)' ),
- 'value' => ini_get( 'memory_limit' ),
- );
- } else {
- $fields['memory_limit'] = array(
- 'label' => __( 'PHP memory limit' ),
- 'value' => ini_get( 'memory_limit' ),
- );
- }
+ $fields['max_input_variables'] = array(
+ 'label' => __( 'PHP max input variables' ),
+ 'value' => ini_get( 'max_input_vars' ),
+ );
+ $fields['time_limit'] = array(
+ 'label' => __( 'PHP time limit' ),
+ 'value' => ini_get( 'max_execution_time' ),
+ );
- $fields['max_input_time'] = array(
- 'label' => __( 'Max input time' ),
- 'value' => ini_get( 'max_input_time' ),
+ if ( WP_Site_Health::get_instance()->php_memory_limit !== ini_get( 'memory_limit' ) ) {
+ $fields['memory_limit'] = array(
+ 'label' => __( 'PHP memory limit' ),
+ 'value' => WP_Site_Health::get_instance()->php_memory_limit,
);
- $fields['upload_max_filesize'] = array(
- 'label' => __( 'Upload max filesize' ),
- 'value' => ini_get( 'upload_max_filesize' ),
+ $fields['admin_memory_limit'] = array(
+ 'label' => __( 'PHP memory limit (only for admin screens)' ),
+ 'value' => ini_get( 'memory_limit' ),
);
- $fields['php_post_max_size'] = array(
- 'label' => __( 'PHP post max size' ),
- 'value' => ini_get( 'post_max_size' ),
+ } else {
+ $fields['memory_limit'] = array(
+ 'label' => __( 'PHP memory limit' ),
+ 'value' => ini_get( 'memory_limit' ),
);
}
+ $fields['max_input_time'] = array(
+ 'label' => __( 'Max input time' ),
+ 'value' => ini_get( 'max_input_time' ),
+ );
+ $fields['upload_max_filesize'] = array(
+ 'label' => __( 'Upload max filesize' ),
+ 'value' => ini_get( 'upload_max_filesize' ),
+ );
+ $fields['php_post_max_size'] = array(
+ 'label' => __( 'PHP post max size' ),
+ 'value' => ini_get( 'post_max_size' ),
+ );
+
if ( function_exists( 'curl_version' ) ) {
$curl = curl_version();
@@ -676,47 +663,35 @@ private static function get_wp_media(): array {
'value' => ( $imagick_version ) ? $imagick_version : __( 'Not available' ),
);
- if ( ! function_exists( 'ini_get' ) ) {
- $fields['ini_get'] = array(
- 'label' => __( 'File upload settings' ),
- 'value' => sprintf(
- /* translators: %s: ini_get() */
- __( 'Unable to determine some settings, as the %s function has been disabled.' ),
- 'ini_get()'
- ),
- 'debug' => 'ini_get() is disabled',
- );
- } else {
- // Get the PHP ini directive values.
- $file_uploads = ini_get( 'file_uploads' );
- $post_max_size = ini_get( 'post_max_size' );
- $upload_max_filesize = ini_get( 'upload_max_filesize' );
- $max_file_uploads = ini_get( 'max_file_uploads' );
- $effective = min( wp_convert_hr_to_bytes( $post_max_size ), wp_convert_hr_to_bytes( $upload_max_filesize ) );
-
- // Add info in Media section.
- $fields['file_uploads'] = array(
- 'label' => __( 'File uploads' ),
- 'value' => $file_uploads ? __( 'Enabled' ) : __( 'Disabled' ),
- 'debug' => $file_uploads,
- );
- $fields['post_max_size'] = array(
- 'label' => __( 'Max size of post data allowed' ),
- 'value' => $post_max_size,
- );
- $fields['upload_max_filesize'] = array(
- 'label' => __( 'Max size of an uploaded file' ),
- 'value' => $upload_max_filesize,
- );
- $fields['max_effective_size'] = array(
- 'label' => __( 'Max effective file size' ),
- 'value' => size_format( $effective ),
- );
- $fields['max_file_uploads'] = array(
- 'label' => __( 'Max simultaneous file uploads' ),
- 'value' => $max_file_uploads,
- );
- }
+ // Get the PHP ini directive values.
+ $file_uploads = ini_get( 'file_uploads' );
+ $post_max_size = ini_get( 'post_max_size' );
+ $upload_max_filesize = ini_get( 'upload_max_filesize' );
+ $max_file_uploads = ini_get( 'max_file_uploads' );
+ $effective = min( wp_convert_hr_to_bytes( $post_max_size ), wp_convert_hr_to_bytes( $upload_max_filesize ) );
+
+ // Add info in Media section.
+ $fields['file_uploads'] = array(
+ 'label' => __( 'File uploads' ),
+ 'value' => $file_uploads ? __( 'Enabled' ) : __( 'Disabled' ),
+ 'debug' => $file_uploads,
+ );
+ $fields['post_max_size'] = array(
+ 'label' => __( 'Max size of post data allowed' ),
+ 'value' => $post_max_size,
+ );
+ $fields['upload_max_filesize'] = array(
+ 'label' => __( 'Max size of an uploaded file' ),
+ 'value' => $upload_max_filesize,
+ );
+ $fields['max_effective_size'] = array(
+ 'label' => __( 'Max effective file size' ),
+ 'value' => size_format( $effective ),
+ );
+ $fields['max_file_uploads'] = array(
+ 'label' => __( 'Max simultaneous file uploads' ),
+ 'value' => $max_file_uploads,
+ );
// If Imagick is used as our editor, provide some more information about its limitations.
if ( 'WP_Image_Editor_Imagick' === _wp_image_editor_choose() && isset( $imagick ) && $imagick instanceof Imagick ) {
@@ -1977,9 +1952,7 @@ public static function get_sizes() {
* from causing a timeout. The default value is 30 seconds, and some
* hosts do not allow you to read configuration values.
*/
- if ( function_exists( 'ini_get' ) ) {
- $max_execution_time = ini_get( 'max_execution_time' );
- }
+ $max_execution_time = ini_get( 'max_execution_time' );
/*
* The max_execution_time defaults to 0 when PHP runs from cli.
diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php
index 415004cff4845..1a7a35f63e126 100644
--- a/src/wp-admin/includes/class-wp-site-health.php
+++ b/src/wp-admin/includes/class-wp-site-health.php
@@ -2322,16 +2322,6 @@ public function get_test_file_uploads() {
'test' => 'file_uploads',
);
- if ( ! function_exists( 'ini_get' ) ) {
- $result['status'] = 'critical';
- $result['description'] .= sprintf(
- /* translators: %s: ini_get() */
- __( 'The %s function has been disabled, some media settings are unavailable because of this.' ),
- 'ini_get()'
- );
- return $result;
- }
-
if ( empty( ini_get( 'file_uploads' ) ) ) {
$result['status'] = 'critical';
$result['description'] .= sprintf(
diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php
index d329b3463bb50..f4b60dfbd4e3a 100644
--- a/src/wp-includes/functions.php
+++ b/src/wp-includes/functions.php
@@ -8878,12 +8878,7 @@ function recurse_dirsize( $directory, $exclude = null, $max_execution_time = nul
if ( null === $max_execution_time ) {
// Keep the previous behavior but attempt to prevent fatal errors from timeout if possible.
- if ( function_exists( 'ini_get' ) ) {
- $max_execution_time = ini_get( 'max_execution_time' );
- } else {
- // Disable...
- $max_execution_time = 0;
- }
+ $max_execution_time = ini_get( 'max_execution_time' );
// Leave 1 second "buffer" for other operations if $max_execution_time has reasonable value.
if ( $max_execution_time > 10 ) {
From 93b45f46f6b2723d2252217e76f3aa67faa92d28 Mon Sep 17 00:00:00 2001
From: westonruter
Date: Sun, 5 Jul 2026 00:27:24 +0000
Subject: [PATCH 3/7] Docs: Modernize and improve specificity of types in
`WP_Error` class.
This brings the `WP_Error` class to full PHPStan rule level 10 compliance.
Also make use of null coalescing operator where appropriate, and simplify `has_errors()` method.
Developed in https://github.com/WordPress/wordpress-develop/pull/12405.
Follow-up to r42761, r49115, r49116.
See #64898, #64897.
git-svn-id: https://develop.svn.wordpress.org/trunk@62635 602fd350-edb4-49c9-b593-d223f7449a82
---
src/wp-includes/class-wp-error.php | 31 ++++++++++++++----------------
1 file changed, 14 insertions(+), 17 deletions(-)
diff --git a/src/wp-includes/class-wp-error.php b/src/wp-includes/class-wp-error.php
index de721468fdf13..d5b3dc729a0c1 100644
--- a/src/wp-includes/class-wp-error.php
+++ b/src/wp-includes/class-wp-error.php
@@ -21,7 +21,7 @@ class WP_Error {
* Stores the list of errors.
*
* @since 2.1.0
- * @var array
+ * @var array
*/
public $errors = array();
@@ -29,7 +29,7 @@ class WP_Error {
* Stores the most recently added data for each error code.
*
* @since 2.1.0
- * @var array
+ * @var array
*/
public $error_data = array();
@@ -37,7 +37,7 @@ class WP_Error {
* Stores previously added data added for error codes, oldest-to-newest by code.
*
* @since 5.6.0
- * @var array[]
+ * @var array
*/
protected $additional_data = array();
@@ -71,7 +71,7 @@ public function __construct( $code = '', $message = '', $data = '' ) {
*
* @since 2.1.0
*
- * @return array List of error codes, if available.
+ * @return list List of error codes, if available.
*/
public function get_error_codes() {
if ( ! $this->has_errors() ) {
@@ -111,18 +111,14 @@ public function get_error_messages( $code = '' ) {
// Return all messages if no code specified.
if ( empty( $code ) ) {
$all_messages = array();
- foreach ( (array) $this->errors as $code => $messages ) {
+ foreach ( (array) $this->errors as $messages ) {
$all_messages = array_merge( $all_messages, $messages );
}
return $all_messages;
}
- if ( isset( $this->errors[ $code ] ) ) {
- return $this->errors[ $code ];
- } else {
- return array();
- }
+ return $this->errors[ $code ] ?? array();
}
/**
@@ -161,9 +157,7 @@ public function get_error_data( $code = '' ) {
$code = $this->get_error_code();
}
- if ( isset( $this->error_data[ $code ] ) ) {
- return $this->error_data[ $code ];
- }
+ return $this->error_data[ $code ] ?? null;
}
/**
@@ -174,10 +168,7 @@ public function get_error_data( $code = '' ) {
* @return bool If the instance contains errors.
*/
public function has_errors() {
- if ( ! empty( $this->errors ) ) {
- return true;
- }
- return false;
+ return (bool) $this->errors;
}
/**
@@ -188,6 +179,7 @@ public function has_errors() {
* @param string|int $code Error code.
* @param string $message Error message.
* @param mixed $data Optional. Error data. Default empty string.
+ * @return void
*/
public function add( $code, $message, $data = '' ) {
$this->errors[ $code ][] = $message;
@@ -217,6 +209,7 @@ public function add( $code, $message, $data = '' ) {
*
* @param mixed $data Error data.
* @param string|int $code Error code.
+ * @return void
*/
public function add_data( $data, $code = '' ) {
if ( empty( $code ) ) {
@@ -265,6 +258,7 @@ public function get_all_error_data( $code = '' ) {
* @since 4.1.0
*
* @param string|int $code Error code.
+ * @return void
*/
public function remove( $code ) {
unset( $this->errors[ $code ] );
@@ -278,6 +272,7 @@ public function remove( $code ) {
* @since 5.6.0
*
* @param WP_Error $error Error object to merge.
+ * @return void
*/
public function merge_from( WP_Error $error ) {
static::copy_errors( $error, $this );
@@ -289,6 +284,7 @@ public function merge_from( WP_Error $error ) {
* @since 5.6.0
*
* @param WP_Error $error Error object to export into.
+ * @return void
*/
public function export_to( WP_Error $error ) {
static::copy_errors( $this, $error );
@@ -301,6 +297,7 @@ public function export_to( WP_Error $error ) {
*
* @param WP_Error $from The WP_Error to copy from.
* @param WP_Error $to The WP_Error to copy to.
+ * @return void
*/
protected static function copy_errors( WP_Error $from, WP_Error $to ) {
foreach ( $from->get_error_codes() as $code ) {
From f01bda1fbdd9447a819ca986132399cfd2b9859f Mon Sep 17 00:00:00 2001
From: westonruter
Date: Sun, 5 Jul 2026 00:46:16 +0000
Subject: [PATCH 4/7] Filesystem: Fix subdirectory recursion in
`WP_Filesystem_Direct::chgrp()` and `::chown()`.
This applies the equivalent fix which had previously been done to `::chmod()`. Tests are added for all three methods.
Developed in subset of https://github.com/WordPress/wordpress-develop/pull/11593.
Follow-up to r12997.
Fixes #65584.
See #65409, #11261.
git-svn-id: https://develop.svn.wordpress.org/trunk@62636 602fd350-edb4-49c9-b593-d223f7449a82
---
.../includes/class-wp-filesystem-direct.php | 8 ++---
.../filesystem/wpFilesystemDirect/base.php | 2 +-
.../filesystem/wpFilesystemDirect/chgrp.php | 32 +++++++++++++++++++
.../filesystem/wpFilesystemDirect/chmod.php | 27 ++++++++++++++++
.../filesystem/wpFilesystemDirect/chown.php | 32 +++++++++++++++++++
5 files changed, 96 insertions(+), 5 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php
index a4b197c15229f..c204951a49adb 100644
--- a/src/wp-admin/includes/class-wp-filesystem-direct.php
+++ b/src/wp-admin/includes/class-wp-filesystem-direct.php
@@ -139,8 +139,8 @@ public function chgrp( $file, $group, $recursive = false ) {
$file = trailingslashit( $file );
$filelist = $this->dirlist( $file );
- foreach ( $filelist as $filename ) {
- $this->chgrp( $file . $filename, $group, $recursive );
+ foreach ( $filelist as $file_listing ) {
+ $this->chgrp( $file . $file_listing['name'], $group, $recursive );
}
return true;
@@ -227,8 +227,8 @@ public function chown( $file, $owner, $recursive = false ) {
// Is a directory, and we want recursive.
$filelist = $this->dirlist( $file );
- foreach ( $filelist as $filename ) {
- $this->chown( $file . '/' . $filename, $owner, $recursive );
+ foreach ( $filelist as $file_listing ) {
+ $this->chown( $file . '/' . $file_listing['name'], $owner, $recursive );
}
return true;
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/base.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/base.php
index 96b449dd0306a..4cdeeca73181e 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/base.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/base.php
@@ -17,7 +17,7 @@ abstract class WP_Filesystem_Direct_UnitTestCase extends WP_UnitTestCase {
/**
* The file structure for tests.
*
- * @var array
+ * @var array
*/
protected static $file_structure = array();
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chgrp.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chgrp.php
index a56e5c5ac2b41..ae232b6a56668 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chgrp.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chgrp.php
@@ -29,4 +29,36 @@ class Tests_Filesystem_WpFilesystemDirect_Chgrp extends WP_Filesystem_Direct_Uni
public function test_should_fail_to_change_file_group( $path ) {
$this->assertFalse( self::$filesystem->chgrp( self::$file_structure['test_dir']['path'] . $path, 0 ) );
}
+
+ /**
+ * Tests that recursive {@see WP_Filesystem_Direct::chgrp()} descends into subdirectories.
+ *
+ * The resulting group cannot be asserted without elevated privileges, so recursion is
+ * verified by recording the paths passed to {@see WP_Filesystem_Direct::chgrp()}. Changing each item to its current
+ * group is a permitted no-op that avoids requiring root and its "Operation not permitted" warning.
+ *
+ * @ticket 65584
+ */
+ public function test_should_recurse_into_subdirectories(): void {
+ $directory = untrailingslashit( self::$file_structure['test_dir']['path'] );
+ $nested_file = self::$file_structure['subfile']['path'];
+
+ $spy = new class( null ) extends WP_Filesystem_Direct {
+ /** @var string[] */
+ public array $visited = array();
+
+ public function chgrp( $file, $group, $recursive = false ) {
+ $this->visited[] = $file;
+ return parent::chgrp( $file, $group, $recursive );
+ }
+ };
+
+ $spy->chgrp( $directory, (int) filegroup( $directory ), true );
+
+ $this->assertContains(
+ $nested_file,
+ $spy->visited,
+ 'chgrp() did not recurse into the nested subdirectory.'
+ );
+ }
}
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php
index 4deb47c4f09fb..fef30631730e0 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chmod.php
@@ -74,4 +74,31 @@ public function data_should_set_mode_when_not_passed() {
),
);
}
+
+ /**
+ * Tests that recursive {@see WP_Filesystem_Direct::chmod()} applies the mode to files in subdirectories.
+ *
+ * @ticket 65584
+ */
+ public function test_should_change_mode_recursively(): void {
+ if ( self::is_windows() ) {
+ $this->markTestSkipped( 'chmod() does not support octal modes on Windows.' );
+ }
+
+ $directory = untrailingslashit( self::$file_structure['test_dir']['path'] );
+ $nested_file = self::$file_structure['subfile']['path'];
+
+ $this->assertTrue(
+ self::$filesystem->chmod( $directory, 0640, true ),
+ 'chmod() did not report success.'
+ );
+
+ clearstatcache();
+
+ $this->assertSame(
+ '640',
+ self::$filesystem->getchmod( $nested_file ),
+ 'The mode was not applied to a file in a nested subdirectory.'
+ );
+ }
}
diff --git a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chown.php b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chown.php
index 040693b03c54c..709959a7fab43 100644
--- a/tests/phpunit/tests/filesystem/wpFilesystemDirect/chown.php
+++ b/tests/phpunit/tests/filesystem/wpFilesystemDirect/chown.php
@@ -29,4 +29,36 @@ class Tests_Filesystem_WpFilesystemDirect_Chown extends WP_Filesystem_Direct_Uni
public function test_should_return_false( $path ) {
$this->assertFalse( self::$filesystem->chown( $path, fileowner( __FILE__ ) ) );
}
+
+ /**
+ * Tests that recursive {@see WP_Filesystem_Direct::chown()} descends into subdirectories.
+ *
+ * The resulting owner cannot be asserted without elevated privileges, so recursion is
+ * verified by recording the paths passed to {@see WP_Filesystem_Direct::chown()}. Changing each item to its current
+ * owner is a permitted no-op that avoids requiring root and its "Operation not permitted" warning.
+ *
+ * @ticket 65584
+ */
+ public function test_should_recurse_into_subdirectories(): void {
+ $directory = untrailingslashit( self::$file_structure['test_dir']['path'] );
+ $nested_file = self::$file_structure['subfile']['path'];
+
+ $spy = new class( null ) extends WP_Filesystem_Direct {
+ /** @var string[] */
+ public array $visited = array();
+
+ public function chown( $file, $owner, $recursive = false ) {
+ $this->visited[] = $file;
+ return parent::chown( $file, $owner, $recursive );
+ }
+ };
+
+ $spy->chown( $directory, (int) fileowner( $directory ), true );
+
+ $this->assertContains(
+ $nested_file,
+ $spy->visited,
+ 'chown() did not recurse into the nested subdirectory.'
+ );
+ }
}
From 2252f6d8ab7d488319cd9ae1bfef997ea2f68b3e Mon Sep 17 00:00:00 2001
From: westonruter
Date: Sun, 5 Jul 2026 01:15:23 +0000
Subject: [PATCH 5/7] Filesystem API: Improve type safety across the transport
classes.
Change the optional constructor argument of `WP_Filesystem_FTPext`, `WP_Filesystem_ftpsockets`, and `WP_Filesystem_SSH2` from an empty string default to an empty `array`, matching how the argument is actually consumed, and improve the associated DocBlocks.
These classes were also brought to adherence with PHPStan rule level 10:
* Add `FileListing` and `Options` array shapes, and initialize each transport's `$options` to a complete default array before any early return.
* Correct several inaccurate `@return` descriptions, including the `group()` methods that had been describing the owner.
* Allow `WP_Filesystem_SSH2::connect()` to be retried after a failed connection attempt.
* Stop `WP_Filesystem_FTPext::parselisting()` from leaking its intermediate date-parsing keys into the returned listing.
* Add `ext-ftp` and `ext-ssh2` to the suggested extensions in `composer.json`.
Developed in https://github.com/WordPress/wordpress-develop/pull/11593.
Follow-up to r62635, r62636.
Props soean, westonruter, mukesh27.
See #65584, #64898.
Fixes #65409.
git-svn-id: https://develop.svn.wordpress.org/trunk@62637 602fd350-edb4-49c9-b593-d223f7449a82
---
composer.json | 4 +-
.../includes/class-wp-filesystem-base.php | 62 +++++---
.../includes/class-wp-filesystem-direct.php | 20 ++-
.../includes/class-wp-filesystem-ftpext.php | 142 ++++++++++++-----
.../class-wp-filesystem-ftpsockets.php | 116 +++++++++++---
.../includes/class-wp-filesystem-ssh2.php | 145 +++++++++++++-----
6 files changed, 363 insertions(+), 126 deletions(-)
diff --git a/composer.json b/composer.json
index a309ae762ac1a..b847ec2b61b55 100644
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,9 @@
},
"suggest": {
"ext-dom": "*",
- "ext-mysqli": "*"
+ "ext-ftp": "*",
+ "ext-mysqli": "*",
+ "ext-ssh2": "*"
},
"require-dev": {
"composer/ca-bundle": "1.5.12",
diff --git a/src/wp-admin/includes/class-wp-filesystem-base.php b/src/wp-admin/includes/class-wp-filesystem-base.php
index 125c2d3b9a8b0..4972c8421cf11 100644
--- a/src/wp-admin/includes/class-wp-filesystem-base.php
+++ b/src/wp-admin/includes/class-wp-filesystem-base.php
@@ -10,6 +10,23 @@
* Base WordPress Filesystem class which Filesystem implementations extend.
*
* @since 2.5.0
+ *
+ * @phpstan-type FileListing array{
+ * name: string,
+ * perms?: string,
+ * permsn?: string,
+ * number?: int|string|false,
+ * owner?: string|int<1, max>|false,
+ * group?: string|int<1, max>|false,
+ * size: int|string|false,
+ * lastmodunix?: int|string|false,
+ * lastmod?: string|false,
+ * time: int|string|false,
+ * type: 'd'|'f'|'l',
+ * islink?: bool,
+ * isdir?: bool,
+ * files?: mixed[]|false, // The mixed[] is actually FileListing[] but PHPStan does not support recursive or self-referencing array shapes.
+ * }
*/
#[AllowDynamicProperties]
class WP_Filesystem_Base {
@@ -26,7 +43,7 @@ class WP_Filesystem_Base {
* Cached list of local filepaths to mapped remote filepaths.
*
* @since 2.7.0
- * @var array
+ * @var array
*/
public $cache = array();
@@ -44,6 +61,7 @@ class WP_Filesystem_Base {
public $errors = null;
/**
+ * @var array
*/
public $options = array();
@@ -52,7 +70,7 @@ class WP_Filesystem_Base {
*
* @since 2.7.0
*
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function abspath() {
$folder = $this->find_folder( ABSPATH );
@@ -73,7 +91,7 @@ public function abspath() {
*
* @since 2.7.0
*
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function wp_content_dir() {
return $this->find_folder( WP_CONTENT_DIR );
@@ -84,7 +102,7 @@ public function wp_content_dir() {
*
* @since 2.7.0
*
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function wp_plugins_dir() {
return $this->find_folder( WP_PLUGIN_DIR );
@@ -97,10 +115,10 @@ public function wp_plugins_dir() {
*
* @param string|false $theme Optional. The theme stylesheet or template for the directory.
* Default false.
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function wp_themes_dir( $theme = false ) {
- $theme_root = get_theme_root( $theme );
+ $theme_root = get_theme_root( is_string( $theme ) ? $theme : '' );
// Account for relative theme roots.
if ( '/themes' === $theme_root || ! is_dir( $theme_root ) ) {
@@ -115,7 +133,7 @@ public function wp_themes_dir( $theme = false ) {
*
* @since 3.2.0
*
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function wp_lang_dir() {
return $this->find_folder( WP_LANG_DIR );
@@ -134,7 +152,7 @@ public function wp_lang_dir() {
*
* @param string $base Optional. The folder to start searching from. Default '.'.
* @param bool $verbose Optional. True to display debug information. Default false.
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function find_base_dir( $base = '.', $verbose = false ) {
_deprecated_function( __FUNCTION__, '2.7.0', 'WP_Filesystem_Base::abspath() or WP_Filesystem_Base::wp_*_dir()' );
@@ -155,7 +173,7 @@ public function find_base_dir( $base = '.', $verbose = false ) {
*
* @param string $base Optional. The folder to start searching from. Default '.'.
* @param bool $verbose Optional. True to display debug information. Default false.
- * @return string The location of the remote path.
+ * @return string|false The location of the remote path, or false on failure.
*/
public function get_base_dir( $base = '.', $verbose = false ) {
_deprecated_function( __FUNCTION__, '2.7.0', 'WP_Filesystem_Base::abspath() or WP_Filesystem_Base::wp_*_dir()' );
@@ -194,7 +212,9 @@ public function find_folder( $folder ) {
}
if ( $folder === $dir ) {
- return trailingslashit( constant( $constant ) );
+ /** @var string $constant_value */
+ $constant_value = constant( $constant );
+ return trailingslashit( $constant_value );
}
}
@@ -205,7 +225,9 @@ public function find_folder( $folder ) {
}
if ( 0 === stripos( $folder, $dir ) ) { // $folder starts with $dir.
- $potential_folder = preg_replace( '#^' . preg_quote( $dir, '#' ) . '/#i', trailingslashit( constant( $constant ) ), $folder );
+ /** @var string $constant_value */
+ $constant_value = constant( $constant );
+ $potential_folder = (string) preg_replace( '#^' . preg_quote( $dir, '#' ) . '/#i', trailingslashit( $constant_value ), $folder );
$potential_folder = trailingslashit( $potential_folder );
if ( $this->is_dir( $potential_folder ) ) {
@@ -221,7 +243,7 @@ public function find_folder( $folder ) {
return trailingslashit( $folder );
}
- $folder = preg_replace( '|^([a-z]{1}):|i', '', $folder ); // Strip out Windows drive letter if it's there.
+ $folder = (string) preg_replace( '|^([a-z]{1}):|i', '', $folder ); // Strip out Windows drive letter if it's there.
$folder = str_replace( '\\', '/', $folder ); // Windows path sanitization.
if ( isset( $this->cache[ $folder ] ) ) {
@@ -258,7 +280,8 @@ public function find_folder( $folder ) {
*/
public function search_for_folder( $folder, $base = '.', $loop = false ) {
if ( empty( $base ) || '.' === $base ) {
- $base = trailingslashit( $this->cwd() );
+ $cwd = $this->cwd();
+ $base = is_string( $cwd ) ? trailingslashit( $cwd ) : '/';
}
$folder = untrailingslashit( $folder );
@@ -420,7 +443,7 @@ public function getchmod( $file ) {
public function getnumchmodfromh( $mode ) {
$realmode = '';
$legal = array( '', 'w', 'r', 'x', '-' );
- $attarray = preg_split( '//', $mode );
+ $attarray = (array) preg_split( '//', $mode );
for ( $i = 0, $c = count( $attarray ); $i < $c; $i++ ) {
$key = array_search( $attarray[ $i ], $legal, true );
@@ -440,9 +463,9 @@ public function getnumchmodfromh( $mode ) {
$mode = strtr( $mode, $trans );
$newmode = $mode[0];
- $newmode .= $mode[1] + $mode[2] + $mode[3];
- $newmode .= $mode[4] + $mode[5] + $mode[6];
- $newmode .= $mode[7] + $mode[8] + $mode[9];
+ $newmode .= (int) $mode[1] + (int) $mode[2] + (int) $mode[3];
+ $newmode .= (int) $mode[4] + (int) $mode[5] + (int) $mode[6];
+ $newmode .= (int) $mode[7] + (int) $mode[8] + (int) $mode[9];
return $newmode;
}
@@ -508,7 +531,7 @@ public function get_contents( $file ) {
* @abstract
*
* @param string $file Path to the file.
- * @return array|false File contents in an array on success, false on failure.
+ * @return string[]|false File contents in an array on success, false on failure.
*/
public function get_contents_array( $file ) {
return false;
@@ -851,12 +874,13 @@ public function rmdir( $path, $recursive = false ) {
* False if not available.
* @type string|false $lastmod Last modified month (3 letters) and day (without leading 0), or
* false if not available.
- * @type string|false $time Last modified time, or false if not available.
+ * @type int|string|false $time Last modified time. A Unix timestamp on FTP transports, or false if not available.
* @type string $type Type of resource. 'f' for file, 'd' for directory, 'l' for link.
* @type array|false $files If a directory and `$recursive` is true, contains another array of
* files. False if unable to list directory contents.
* }
* }
+ * @phpstan-return array|false
*/
public function dirlist( $path, $include_hidden = true, $recursive = false ) {
return false;
diff --git a/src/wp-admin/includes/class-wp-filesystem-direct.php b/src/wp-admin/includes/class-wp-filesystem-direct.php
index c204951a49adb..dad8e329b2421 100644
--- a/src/wp-admin/includes/class-wp-filesystem-direct.php
+++ b/src/wp-admin/includes/class-wp-filesystem-direct.php
@@ -12,6 +12,7 @@
* @since 2.5.0
*
* @see WP_Filesystem_Base
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
*/
class WP_Filesystem_Direct extends WP_Filesystem_Base {
@@ -23,6 +24,8 @@ class WP_Filesystem_Direct extends WP_Filesystem_Base {
* @param mixed $arg Not used.
*/
public function __construct( $arg ) {
+ // The $arg parameter is required for signature parity with the other transports, but is unused here.
+ unset( $arg );
$this->method = 'direct';
$this->errors = new WP_Error();
}
@@ -45,7 +48,7 @@ public function get_contents( $file ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return array|false File contents in an array on success, false on failure.
+ * @return string[]|false File contents in an array on success, false on failure.
*/
public function get_contents_array( $file ) {
return @file( $file );
@@ -138,6 +141,9 @@ public function chgrp( $file, $group, $recursive = false ) {
// Is a directory, and we want recursive.
$file = trailingslashit( $file );
$filelist = $this->dirlist( $file );
+ if ( false === $filelist ) {
+ return false;
+ }
foreach ( $filelist as $file_listing ) {
$this->chgrp( $file . $file_listing['name'], $group, $recursive );
@@ -226,6 +232,9 @@ public function chown( $file, $owner, $recursive = false ) {
// Is a directory, and we want recursive.
$filelist = $this->dirlist( $file );
+ if ( false === $filelist ) {
+ return false;
+ }
foreach ( $filelist as $file_listing ) {
$this->chown( $file . '/' . $file_listing['name'], $owner, $recursive );
@@ -240,7 +249,7 @@ public function chown( $file, $owner, $recursive = false ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return string|false Username of the owner on success, false on failure.
+ * @return string|int<1, max>|false Username of the owner on success, or UID of file owner if not available; false on failure.
*/
public function owner( $file ) {
$owneruid = @fileowner( $file );
@@ -285,7 +294,7 @@ public function getchmod( $file ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return string|false The group on success, false on failure.
+ * @return string|int<1, max>|false Group name on success, or GID of the file's group if not available; false on failure.
*/
public function group( $file ) {
$gid = @filegroup( $file );
@@ -639,6 +648,7 @@ public function rmdir( $path, $recursive = false ) {
* files. False if unable to list directory contents.
* }
* }
+ * @phpstan-return array|false
*/
public function dirlist( $path, $include_hidden = true, $recursive = false ) {
if ( $this->is_file( $path ) ) {
@@ -684,8 +694,8 @@ public function dirlist( $path, $include_hidden = true, $recursive = false ) {
$struc['group'] = $this->group( $path . $entry );
$struc['size'] = $this->size( $path . $entry );
$struc['lastmodunix'] = $this->mtime( $path . $entry );
- $struc['lastmod'] = gmdate( 'M j', $struc['lastmodunix'] );
- $struc['time'] = gmdate( 'h:i:s', $struc['lastmodunix'] );
+ $struc['lastmod'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false;
+ $struc['time'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false;
$struc['type'] = $this->is_dir( $path . $entry ) ? 'd' : 'f';
if ( 'd' === $struc['type'] ) {
diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpext.php b/src/wp-admin/includes/class-wp-filesystem-ftpext.php
index 0ab4bc17c32a2..844c4e9c5b95b 100644
--- a/src/wp-admin/includes/class-wp-filesystem-ftpext.php
+++ b/src/wp-admin/includes/class-wp-filesystem-ftpext.php
@@ -12,6 +12,14 @@
* @since 2.5.0
*
* @see WP_Filesystem_Base
+ * @phpstan-type Options array{
+ * hostname: string,
+ * username: string,
+ * password: string,
+ * port: non-negative-int,
+ * ssl: bool,
+ * }
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
*/
class WP_Filesystem_FTPext extends WP_Filesystem_Base {
@@ -21,16 +29,45 @@ class WP_Filesystem_FTPext extends WP_Filesystem_Base {
*/
public $link;
+ /**
+ * @since 7.1.0
+ * @var array
+ * @phpstan-var Options
+ */
+ public $options;
+
/**
* Constructor.
*
* @since 2.5.0
*
- * @param array $opt
+ * @param array $opt {
+ * Array of connection options.
+ *
+ * @type string $hostname Required. FTP server hostname.
+ * @type string $username Required. FTP username.
+ * @type string $password Required. FTP password.
+ * @type int $port Optional. FTP server port. Default 21.
+ * @type string $connection_type Optional. Connection type. Use 'ftps' to enable SSL.
+ * }
+ * @phpstan-param array{
+ * hostname: non-empty-string,
+ * username: non-empty-string,
+ * password: string,
+ * port?: non-negative-int,
+ * connection_type?: 'ftps',
+ * }|null $opt
*/
- public function __construct( $opt = '' ) {
- $this->method = 'ftpext';
- $this->errors = new WP_Error();
+ public function __construct( $opt = null ) {
+ $this->method = 'ftpext';
+ $this->errors = new WP_Error();
+ $this->options = array(
+ 'port' => 21,
+ 'hostname' => '',
+ 'username' => '',
+ 'password' => '',
+ 'ssl' => false,
+ );
// Check if possible to use ftp functions.
if ( ! extension_loaded( 'ftp' ) ) {
@@ -43,9 +80,11 @@ public function __construct( $opt = '' ) {
define( 'FS_TIMEOUT', 4 * MINUTE_IN_SECONDS );
}
- if ( empty( $opt['port'] ) ) {
- $this->options['port'] = 21;
- } else {
+ if ( ! is_array( $opt ) ) {
+ $opt = array();
+ }
+
+ if ( ! empty( $opt['port'] ) ) {
$this->options['port'] = $opt['port'];
}
@@ -68,8 +107,6 @@ public function __construct( $opt = '' ) {
$this->options['password'] = $opt['password'];
}
- $this->options['ssl'] = false;
-
if ( isset( $opt['connection_type'] ) && 'ftps' === $opt['connection_type'] ) {
$this->options['ssl'] = true;
}
@@ -83,6 +120,15 @@ public function __construct( $opt = '' ) {
* @return bool True on success, false on failure.
*/
public function connect() {
+ /*
+ * Bail if the constructor recorded a configuration error. Connection and
+ * authentication errors are excluded so that a failed connection attempt
+ * can be retried on the same instance.
+ */
+ if ( $this->errors->has_errors() && ! array_intersect( array( 'connect', 'auth' ), $this->errors->get_error_codes() ) ) {
+ return false;
+ }
+
if ( isset( $this->options['ssl'] ) && $this->options['ssl'] && function_exists( 'ftp_ssl_connect' ) ) {
$this->link = @ftp_ssl_connect( $this->options['hostname'], $this->options['port'], FS_CONNECT_TIMEOUT );
} else {
@@ -135,6 +181,10 @@ public function connect() {
* or if the file couldn't be retrieved.
*/
public function get_contents( $file ) {
+ if ( ! $this->link ) {
+ return false;
+ }
+
$tempfile = wp_tempnam( $file );
$temphandle = fopen( $tempfile, 'w+' );
@@ -168,10 +218,14 @@ public function get_contents( $file ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return array|false File contents in an array on success, false on failure.
+ * @return string[]|false File contents in an array on success, false on failure.
*/
public function get_contents_array( $file ) {
- return explode( "\n", $this->get_contents( $file ) );
+ $contents = $this->get_contents( $file );
+ if ( is_string( $contents ) ) {
+ return explode( "\n", $contents );
+ }
+ return false;
}
/**
@@ -294,7 +348,7 @@ public function chmod( $file, $mode = false, $recursive = false ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return string|false Username of the owner on success, false on failure.
+ * @return string|int<1, max>|false Username of the owner on success, false on failure.
*/
public function owner( $file ) {
$dir = $this->dirlist( $file );
@@ -322,7 +376,7 @@ public function getchmod( $file ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return string|false The group on success, false on failure.
+ * @return string|int<1, max>|false The group on success, false on failure.
*/
public function group( $file ) {
$dir = $this->dirlist( $file );
@@ -465,9 +519,17 @@ public function is_file( $file ) {
* @return bool Whether $path is a directory.
*/
public function is_dir( $path ) {
- $cwd = $this->cwd();
+ $cwd = $this->cwd();
+ if ( false === $cwd ) {
+ return false;
+ }
+
$result = @ftp_chdir( $this->link, trailingslashit( $path ) );
+ if ( ! $this->link ) {
+ return false;
+ }
+
if ( $result && $path === $this->cwd() || $this->cwd() !== $cwd ) {
@ftp_chdir( $this->link, $cwd );
return true;
@@ -622,6 +684,7 @@ public function rmdir( $path, $recursive = false ) {
* @type array|false $files If a directory and `$recursive` is true, contains another array of files.
* False if unable to list directory contents.
* }
+ * @phpstan-return FileListing|''
*/
public function parselisting( $line ) {
static $is_windows = null;
@@ -647,15 +710,9 @@ public function parselisting( $line ) {
$b['type'] = 'f';
}
- $b['size'] = $lucifer[7];
- $b['month'] = $lucifer[1];
- $b['day'] = $lucifer[2];
- $b['year'] = $lucifer[3];
- $b['hour'] = $lucifer[4];
- $b['minute'] = $lucifer[5];
- $b['time'] = mktime( $lucifer[4] + ( strcasecmp( $lucifer[6], 'PM' ) === 0 ? 12 : 0 ), $lucifer[5], 0, $lucifer[1], $lucifer[2], $lucifer[3] );
- $b['am/pm'] = $lucifer[6];
- $b['name'] = $lucifer[8];
+ $b['size'] = $lucifer[7];
+ $b['time'] = mktime( (int) $lucifer[4] + ( strcasecmp( $lucifer[6], 'PM' ) === 0 ? 12 : 0 ), (int) $lucifer[5], 0, (int) $lucifer[1], (int) $lucifer[2], (int) $lucifer[3] );
+ $b['name'] = $lucifer[8];
} elseif ( ! $is_windows ) {
$lucifer = preg_split( '/[ ]/', $line, 9, PREG_SPLIT_NO_EMPTY );
@@ -686,26 +743,26 @@ public function parselisting( $line ) {
$b['size'] = $lucifer[4];
if ( 8 === $lcount ) {
- sscanf( $lucifer[5], '%d-%d-%d', $b['year'], $b['month'], $b['day'] );
- sscanf( $lucifer[6], '%d:%d', $b['hour'], $b['minute'] );
+ sscanf( $lucifer[5], '%d-%d-%d', $year, $month, $day );
+ sscanf( $lucifer[6], '%d:%d', $hour, $minute );
- $b['time'] = mktime( $b['hour'], $b['minute'], 0, $b['month'], $b['day'], $b['year'] );
+ $b['time'] = mktime( (int) $hour, (int) $minute, 0, (int) $month, (int) $day, (int) $year );
$b['name'] = $lucifer[7];
} else {
- $b['month'] = $lucifer[5];
- $b['day'] = $lucifer[6];
+ $month = $lucifer[5];
+ $day = $lucifer[6];
if ( preg_match( '/([0-9]{2}):([0-9]{2})/', $lucifer[7], $l2 ) ) {
- $b['year'] = gmdate( 'Y' );
- $b['hour'] = $l2[1];
- $b['minute'] = $l2[2];
+ $year = gmdate( 'Y' );
+ $hour = $l2[1];
+ $minute = $l2[2];
} else {
- $b['year'] = $lucifer[7];
- $b['hour'] = 0;
- $b['minute'] = 0;
+ $year = $lucifer[7];
+ $hour = 0;
+ $minute = 0;
}
- $b['time'] = strtotime( sprintf( '%d %s %d %02d:%02d', $b['day'], $b['month'], $b['year'], $b['hour'], $b['minute'] ) );
+ $b['time'] = strtotime( sprintf( '%d %s %d %02d:%02d', $day, $month, $year, $hour, $minute ) );
$b['name'] = $lucifer[8];
}
}
@@ -713,10 +770,10 @@ public function parselisting( $line ) {
// Replace symlinks formatted as "source -> target" with just the source name.
if ( isset( $b['islink'] ) && $b['islink'] ) {
- $b['name'] = preg_replace( '/(\s*->\s*.*)$/', '', $b['name'] );
+ $b['name'] = (string) preg_replace( '/(\s*->\s*.*)$/', '', $b['name'] );
}
- return $b;
+ return $b ?? '';
}
/**
@@ -747,14 +804,19 @@ public function parselisting( $line ) {
* False if not available.
* @type string|false $lastmod Last modified month (3 letters) and day (without leading 0), or
* false if not available.
- * @type string|false $time Last modified time, or false if not available.
+ * @type int|string|false $time Last modified time as a Unix timestamp, or false if not available.
* @type string $type Type of resource. 'f' for file, 'd' for directory, 'l' for link.
* @type array|false $files If a directory and `$recursive` is true, contains another array of
* files. False if unable to list directory contents.
* }
* }
+ * @phpstan-return array|false
*/
public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) {
+ if ( ! $this->link ) {
+ return false;
+ }
+
if ( $this->is_file( $path ) ) {
$limit_file = basename( $path );
$path = dirname( $path ) . '/';
@@ -763,11 +825,15 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false
}
$pwd = ftp_pwd( $this->link );
+ if ( ! is_string( $pwd ) ) {
+ return false;
+ }
if ( ! @ftp_chdir( $this->link, $path ) ) { // Can't change to folder = folder doesn't exist.
return false;
}
+ /** @var string[]|false $list */
$list = ftp_rawlist( $this->link, '-a', false );
@ftp_chdir( $this->link, $pwd );
diff --git a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php
index cc665ad9bf7b4..8d8d9ff02d884 100644
--- a/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php
+++ b/src/wp-admin/includes/class-wp-filesystem-ftpsockets.php
@@ -12,6 +12,13 @@
* @since 2.5.0
*
* @see WP_Filesystem_Base
+ * @phpstan-type Options array{
+ * hostname: string,
+ * username: string,
+ * password: string,
+ * port: non-negative-int,
+ * }
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
*/
class WP_Filesystem_ftpsockets extends WP_Filesystem_Base {
@@ -21,16 +28,42 @@ class WP_Filesystem_ftpsockets extends WP_Filesystem_Base {
*/
public $ftp;
+ /**
+ * @since 7.1.0
+ * @var array
+ * @phpstan-var Options
+ */
+ public $options;
+
/**
* Constructor.
*
* @since 2.5.0
*
- * @param array $opt
+ * @param array $opt {
+ * Array of connection options.
+ *
+ * @type string $hostname Required. FTP server hostname.
+ * @type string $username Required. FTP username.
+ * @type string $password Required. FTP password.
+ * @type int $port Optional. FTP server port. Default 21.
+ * }
+ * @phpstan-param array{
+ * hostname: non-empty-string,
+ * username: non-empty-string,
+ * password: string,
+ * port?: non-negative-int,
+ * }|null $opt
*/
- public function __construct( $opt = '' ) {
- $this->method = 'ftpsockets';
- $this->errors = new WP_Error();
+ public function __construct( $opt = null ) {
+ $this->method = 'ftpsockets';
+ $this->errors = new WP_Error();
+ $this->options = array(
+ 'port' => 21,
+ 'hostname' => '',
+ 'username' => '',
+ 'password' => '',
+ );
// Check if possible to use ftp functions.
if ( ! require_once ABSPATH . 'wp-admin/includes/class-ftp.php' ) {
@@ -39,9 +72,11 @@ public function __construct( $opt = '' ) {
$this->ftp = new ftp();
- if ( empty( $opt['port'] ) ) {
- $this->options['port'] = 21;
- } else {
+ if ( ! is_array( $opt ) ) {
+ $opt = array();
+ }
+
+ if ( ! empty( $opt['port'] ) ) {
$this->options['port'] = (int) $opt['port'];
}
@@ -73,6 +108,15 @@ public function __construct( $opt = '' ) {
* @return bool True on success, false on failure.
*/
public function connect() {
+ /*
+ * Bail if the constructor recorded a configuration error. Connection and
+ * authentication errors are excluded so that a failed connection attempt
+ * can be retried on the same instance.
+ */
+ if ( $this->errors->has_errors() && ! array_intersect( array( 'connect', 'auth' ), $this->errors->get_error_codes() ) ) {
+ return false;
+ }
+
if ( ! $this->ftp ) {
return false;
}
@@ -179,10 +223,14 @@ public function get_contents( $file ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return array|false File contents in an array on success, false on failure.
+ * @return string[]|false File contents in an array on success, false on failure.
*/
public function get_contents_array( $file ) {
- return explode( "\n", $this->get_contents( $file ) );
+ $contents = $this->get_contents( $file );
+ if ( is_string( $contents ) ) {
+ return explode( "\n", $contents );
+ }
+ return false;
}
/**
@@ -221,7 +269,7 @@ public function put_contents( $file, $contents, $mode = false ) {
fseek( $temphandle, 0 ); // Skip back to the start of the file being written to.
- $ret = $this->ftp->fput( $file, $temphandle );
+ $ret = (bool) $this->ftp->fput( $file, $temphandle );
reset_mbstring_encoding();
@@ -242,6 +290,9 @@ public function put_contents( $file, $contents, $mode = false ) {
*/
public function cwd() {
$cwd = $this->ftp->pwd();
+ if ( ! is_string( $cwd ) ) {
+ return false;
+ }
if ( $cwd ) {
$cwd = trailingslashit( $cwd );
@@ -259,7 +310,7 @@ public function cwd() {
* @return bool True on success, false on failure.
*/
public function chdir( $dir ) {
- return $this->ftp->chdir( $dir );
+ return (bool) $this->ftp->chdir( $dir );
}
/**
@@ -295,7 +346,7 @@ public function chmod( $file, $mode = false, $recursive = false ) {
}
// chmod the file or directory.
- return $this->ftp->chmod( $file, $mode );
+ return (bool) $this->ftp->chmod( $file, $mode );
}
/**
@@ -304,7 +355,7 @@ public function chmod( $file, $mode = false, $recursive = false ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return string|false Username of the owner on success, false on failure.
+ * @return string|int<1, max>|false Username of the owner on success, false on failure.
*/
public function owner( $file ) {
$dir = $this->dirlist( $file );
@@ -332,7 +383,7 @@ public function getchmod( $file ) {
* @since 2.5.0
*
* @param string $file Path to the file.
- * @return string|false The group on success, false on failure.
+ * @return string|int<1, max>|false The group on success, false on failure.
*/
public function group( $file ) {
$dir = $this->dirlist( $file );
@@ -386,7 +437,7 @@ public function copy( $source, $destination, $overwrite = false, $mode = false )
* @return bool True on success, false on failure.
*/
public function move( $source, $destination, $overwrite = false ) {
- return $this->ftp->rename( $source, $destination );
+ return (bool) $this->ftp->rename( $source, $destination );
}
/**
@@ -407,14 +458,14 @@ public function delete( $file, $recursive = false, $type = false ) {
}
if ( 'f' === $type || $this->is_file( $file ) ) {
- return $this->ftp->delete( $file );
+ return (bool) $this->ftp->delete( $file );
}
if ( ! $recursive ) {
- return $this->ftp->rmdir( $file );
+ return (bool) $this->ftp->rmdir( $file );
}
- return $this->ftp->mdel( $file );
+ return (bool) $this->ftp->mdel( $file );
}
/**
@@ -477,6 +528,9 @@ public function is_file( $file ) {
*/
public function is_dir( $path ) {
$cwd = $this->cwd();
+ if ( ! $cwd ) {
+ return false;
+ }
if ( $this->chdir( $path ) ) {
$this->chdir( $cwd );
@@ -531,7 +585,11 @@ public function atime( $file ) {
* @return int|false Unix timestamp representing modification time, false on failure.
*/
public function mtime( $file ) {
- return $this->ftp->mdtm( $file );
+ $modified_time = $this->ftp->mdtm( $file );
+ if ( false === $modified_time ) {
+ return false;
+ }
+ return (int) $modified_time;
}
/**
@@ -543,7 +601,11 @@ public function mtime( $file ) {
* @return int|false Size of the file in bytes on success, false on failure.
*/
public function size( $file ) {
- return $this->ftp->filesize( $file );
+ $size = $this->ftp->filesize( $file );
+ if ( false === $size ) {
+ return false;
+ }
+ return (int) $size;
}
/**
@@ -640,12 +702,13 @@ public function rmdir( $path, $recursive = false ) {
* False if not available.
* @type string|false $lastmod Last modified month (3 letters) and day (without leading 0), or
* false if not available.
- * @type string|false $time Last modified time, or false if not available.
+ * @type int|string|false $time Last modified time as a Unix timestamp, or false if not available.
* @type string $type Type of resource. 'f' for file, 'd' for directory, 'l' for link.
* @type array|false $files If a directory and `$recursive` is true, contains another array of
* files. False if unable to list directory contents.
* }
* }
+ * @phpstan-return array|false
*/
public function dirlist( $path = '.', $include_hidden = true, $recursive = false ) {
if ( $this->is_file( $path ) ) {
@@ -657,9 +720,10 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false
mbstring_binary_safe_encoding();
+ /** @var array|false $list */
$list = $this->ftp->dirlist( $path );
- if ( empty( $list ) && ! $this->exists( $path ) ) {
+ if ( ! is_array( $list ) || ( empty( $list ) && ! $this->exists( $path ) ) ) {
reset_mbstring_encoding();
@@ -692,12 +756,14 @@ public function dirlist( $path = '.', $include_hidden = true, $recursive = false
}
// Replace symlinks formatted as "source -> target" with just the source name.
- if ( $struc['islink'] ) {
- $struc['name'] = preg_replace( '/(\s*->\s*.*)$/', '', $struc['name'] );
+ if ( $struc['islink'] ?? false ) {
+ $struc['name'] = (string) preg_replace( '/(\s*->\s*.*)$/', '', $struc['name'] );
}
// Add the octal representation of the file permissions.
- $struc['permsn'] = $this->getnumchmodfromh( $struc['perms'] );
+ if ( isset( $struc['perms'] ) ) {
+ $struc['permsn'] = $this->getnumchmodfromh( $struc['perms'] );
+ }
$ret[ $struc['name'] ] = $struc;
}
diff --git a/src/wp-admin/includes/class-wp-filesystem-ssh2.php b/src/wp-admin/includes/class-wp-filesystem-ssh2.php
index 9146045025942..f86b06ce0c4ce 100644
--- a/src/wp-admin/includes/class-wp-filesystem-ssh2.php
+++ b/src/wp-admin/includes/class-wp-filesystem-ssh2.php
@@ -32,18 +32,29 @@
*
* @package WordPress
* @subpackage Filesystem
+ *
+ * @phpstan-type Options array{
+ * hostname: string,
+ * username: string,
+ * password: string|null,
+ * port: non-negative-int,
+ * public_key?: non-empty-string,
+ * private_key?: non-empty-string,
+ * hostkey?: array{ hostkey: non-empty-string },
+ * }
+ * @phpstan-import-type FileListing from WP_Filesystem_Base
*/
class WP_Filesystem_SSH2 extends WP_Filesystem_Base {
/**
* @since 2.7.0
- * @var resource
+ * @var resource|false
*/
public $link = false;
/**
* @since 2.7.0
- * @var resource
+ * @var resource|false
*/
public $sftp_link;
@@ -53,16 +64,46 @@ class WP_Filesystem_SSH2 extends WP_Filesystem_Base {
*/
public $keys = false;
+ /**
+ * @since 7.1.0
+ * @var array
+ * @phpstan-var Options
+ */
+ public $options;
+
/**
* Constructor.
*
* @since 2.7.0
*
- * @param array $opt
- */
- public function __construct( $opt = '' ) {
- $this->method = 'ssh2';
- $this->errors = new WP_Error();
+ * @param array $opt {
+ * Array of connection options.
+ *
+ * @type string $hostname Required. SSH server hostname.
+ * @type string $username Required. SSH username.
+ * @type int $port Optional. SSH server port. Default 22.
+ * @type string $password Optional. SSH password. May be empty when using keys.
+ * @type string $public_key Optional. Path to public key file for publickey authentication.
+ * @type string $private_key Optional. Path to private key file for publickey authentication.
+ * }
+ * @phpstan-param array{
+ * hostname: non-empty-string,
+ * username: non-empty-string,
+ * port?: non-negative-int,
+ * password?: string,
+ * public_key?: non-empty-string,
+ * private_key?: non-empty-string,
+ * }|null $opt
+ */
+ public function __construct( $opt = null ) {
+ $this->method = 'ssh2';
+ $this->errors = new WP_Error();
+ $this->options = array(
+ 'port' => 22,
+ 'hostname' => '',
+ 'username' => '',
+ 'password' => null,
+ );
// Check if possible to use ssh2 functions.
if ( ! extension_loaded( 'ssh2' ) ) {
@@ -70,10 +111,12 @@ public function __construct( $opt = '' ) {
return;
}
+ if ( ! is_array( $opt ) ) {
+ $opt = array();
+ }
+
// Set defaults:
- if ( empty( $opt['port'] ) ) {
- $this->options['port'] = 22;
- } else {
+ if ( ! empty( $opt['port'] ) ) {
$this->options['port'] = $opt['port'];
}
@@ -91,23 +134,20 @@ public function __construct( $opt = '' ) {
$this->options['hostkey'] = array( 'hostkey' => 'ssh-rsa,ssh-ed25519' );
$this->keys = true;
- } elseif ( empty( $opt['username'] ) ) {
- $this->errors->add( 'empty_username', __( 'SSH2 username is required' ) );
}
- if ( ! empty( $opt['username'] ) ) {
+ // A username is always required, whether authenticating with a password or with keys.
+ if ( empty( $opt['username'] ) ) {
+ $this->errors->add( 'empty_username', __( 'SSH2 username is required' ) );
+ } else {
$this->options['username'] = $opt['username'];
}
- if ( empty( $opt['password'] ) ) {
- // Password can be blank if we are using keys.
- if ( ! $this->keys ) {
- $this->errors->add( 'empty_password', __( 'SSH2 password is required' ) );
- } else {
- $this->options['password'] = null;
- }
- } else {
+ if ( ! empty( $opt['password'] ) ) {
$this->options['password'] = $opt['password'];
+ } elseif ( ! $this->keys ) {
+ // Password can be blank if we are using keys.
+ $this->errors->add( 'empty_password', __( 'SSH2 password is required' ) );
}
}
@@ -119,7 +159,16 @@ public function __construct( $opt = '' ) {
* @return bool True on success, false on failure.
*/
public function connect() {
- if ( ! $this->keys ) {
+ /*
+ * Bail if the constructor recorded a configuration error. Connection and
+ * authentication errors are excluded so that a failed connection attempt
+ * can be retried on the same instance.
+ */
+ if ( $this->errors->has_errors() && ! array_intersect( array( 'connect', 'auth' ), $this->errors->get_error_codes() ) ) {
+ return false;
+ }
+
+ if ( ! isset( $this->options['hostkey'] ) ) {
$this->link = @ssh2_connect( $this->options['hostname'], $this->options['port'] );
} else {
$this->link = @ssh2_connect( $this->options['hostname'], $this->options['port'], $this->options['hostkey'] );
@@ -139,7 +188,7 @@ public function connect() {
}
if ( ! $this->keys ) {
- if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ) ) {
+ if ( ! @ssh2_auth_password( $this->link, $this->options['username'], $this->options['password'] ?? '' ) ) {
$this->errors->add(
'auth',
sprintf(
@@ -152,7 +201,7 @@ public function connect() {
return false;
}
} else {
- if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'], $this->options['private_key'], $this->options['password'] ) ) {
+ if ( ! @ssh2_auth_pubkey_file( $this->link, $this->options['username'], $this->options['public_key'] ?? '', $this->options['private_key'] ?? '', $this->options['password'] ?? '' ) ) {
$this->errors->add(
'auth',
sprintf(
@@ -212,6 +261,8 @@ public function sftp_path( $path ) {
* @param bool $returnbool
* @return bool|string True on success, false on failure. String if the command was executed, `$returnbool`
* is false (default), and data from the resulting stream was retrieved.
+ *
+ * @phpstan-return ( $returnbool is true ? bool : string )
*/
public function run_command( $command, $returnbool = false ) {
if ( ! $this->link ) {
@@ -264,7 +315,7 @@ public function get_contents( $file ) {
* @since 2.7.0
*
* @param string $file Path to the file.
- * @return array|false File contents in an array on success, false on failure.
+ * @return string[]|false File contents in an array on success, false on failure.
*/
public function get_contents_array( $file ) {
return file( $this->sftp_path( $file ) );
@@ -301,13 +352,17 @@ public function put_contents( $file, $contents, $mode = false ) {
* @return string|false The current working directory on success, false on failure.
*/
public function cwd() {
+ if ( ! $this->sftp_link ) {
+ return false;
+ }
+
$cwd = ssh2_sftp_realpath( $this->sftp_link, '.' );
- if ( $cwd ) {
- $cwd = trailingslashit( trim( $cwd ) );
+ if ( ! is_string( $cwd ) ) {
+ return false;
}
- return $cwd;
+ return trailingslashit( trim( $cwd ) );
}
/**
@@ -339,10 +394,10 @@ public function chgrp( $file, $group, $recursive = false ) {
}
if ( ! $recursive || ! $this->is_dir( $file ) ) {
- return $this->run_command( sprintf( 'chgrp %s %s', escapeshellarg( $group ), escapeshellarg( $file ) ), true );
+ return $this->run_command( sprintf( 'chgrp %s %s', escapeshellarg( (string) $group ), escapeshellarg( $file ) ), true );
}
- return $this->run_command( sprintf( 'chgrp -R %s %s', escapeshellarg( $group ), escapeshellarg( $file ) ), true );
+ return $this->run_command( sprintf( 'chgrp -R %s %s', escapeshellarg( (string) $group ), escapeshellarg( $file ) ), true );
}
/**
@@ -396,10 +451,10 @@ public function chown( $file, $owner, $recursive = false ) {
}
if ( ! $recursive || ! $this->is_dir( $file ) ) {
- return $this->run_command( sprintf( 'chown %s %s', escapeshellarg( $owner ), escapeshellarg( $file ) ), true );
+ return $this->run_command( sprintf( 'chown %s %s', escapeshellarg( (string) $owner ), escapeshellarg( $file ) ), true );
}
- return $this->run_command( sprintf( 'chown -R %s %s', escapeshellarg( $owner ), escapeshellarg( $file ) ), true );
+ return $this->run_command( sprintf( 'chown -R %s %s', escapeshellarg( (string) $owner ), escapeshellarg( $file ) ), true );
}
/**
@@ -408,7 +463,7 @@ public function chown( $file, $owner, $recursive = false ) {
* @since 2.7.0
*
* @param string $file Path to the file.
- * @return string|false Username of the owner on success, false on failure.
+ * @return string|int<1, max>|false Username of the owner on success, or UID of file owner if not available; false on failure.
*/
public function owner( $file ) {
$owneruid = @fileowner( $this->sftp_path( $file ) );
@@ -436,10 +491,14 @@ public function owner( $file ) {
* @since 2.7.0
*
* @param string $file Path to the file.
- * @return string Mode of the file (the last 3 digits).
+ * @return string Mode of the file (the last 3 digits). Empty string on failure.
*/
public function getchmod( $file ) {
- return substr( decoct( @fileperms( $this->sftp_path( $file ) ) ), -3 );
+ $file_perms = @fileperms( $this->sftp_path( $file ) );
+ if ( ! is_int( $file_perms ) ) {
+ return '';
+ }
+ return substr( decoct( $file_perms ), -3 );
}
/**
@@ -448,7 +507,7 @@ public function getchmod( $file ) {
* @since 2.7.0
*
* @param string $file Path to the file.
- * @return string|false The group on success, false on failure.
+ * @return string|int<1, max>|false Group name on success, or GID of the file's group if not available; false on failure.
*/
public function group( $file ) {
$gid = @filegroup( $this->sftp_path( $file ) );
@@ -526,6 +585,9 @@ public function move( $source, $destination, $overwrite = false ) {
}
}
+ if ( ! $this->sftp_link ) {
+ return false;
+ }
return ssh2_sftp_rename( $this->sftp_link, $source, $destination );
}
@@ -542,6 +604,9 @@ public function move( $source, $destination, $overwrite = false ) {
* @return bool True on success, false on failure.
*/
public function delete( $file, $recursive = false, $type = false ) {
+ if ( ! $this->sftp_link ) {
+ return false;
+ }
if ( 'f' === $type || $this->is_file( $file ) ) {
return ssh2_sftp_unlink( $this->sftp_link, $file );
}
@@ -692,6 +757,9 @@ public function touch( $file, $time = 0, $atime = 0 ) {
* @return bool True on success, false on failure.
*/
public function mkdir( $path, $chmod = false, $chown = false, $chgrp = false ) {
+ if ( ! $this->sftp_link ) {
+ return false;
+ }
$path = untrailingslashit( $path );
if ( empty( $path ) ) {
@@ -768,6 +836,7 @@ public function rmdir( $path, $recursive = false ) {
* files. False if unable to list directory contents.
* }
* }
+ * @phpstan-return array|false
*/
public function dirlist( $path, $include_hidden = true, $recursive = false ) {
if ( $this->is_file( $path ) ) {
@@ -813,8 +882,8 @@ public function dirlist( $path, $include_hidden = true, $recursive = false ) {
$struc['group'] = $this->group( $path . $entry );
$struc['size'] = $this->size( $path . $entry );
$struc['lastmodunix'] = $this->mtime( $path . $entry );
- $struc['lastmod'] = gmdate( 'M j', $struc['lastmodunix'] );
- $struc['time'] = gmdate( 'h:i:s', $struc['lastmodunix'] );
+ $struc['lastmod'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'M j', $struc['lastmodunix'] ) : false;
+ $struc['time'] = is_int( $struc['lastmodunix'] ) ? gmdate( 'h:i:s', $struc['lastmodunix'] ) : false;
$struc['type'] = $this->is_dir( $path . $entry ) ? 'd' : 'f';
if ( 'd' === $struc['type'] ) {
From 9153f73371e006facaca2be3949763cc2474f05b Mon Sep 17 00:00:00 2001
From: kipmyk
Date: Sat, 25 Jul 2026 08:32:34 +0300
Subject: [PATCH 6/7] Plugins: Display required WordPress version for
incompatible plugins.
---
.../class-wp-plugin-install-list-table.php | 10 +++++-
src/wp-admin/includes/plugin-install.php | 10 +++++-
.../tests/admin/wpPluginInstallListTable.php | 31 +++++++++++++++++++
3 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-plugin-install-list-table.php b/src/wp-admin/includes/class-wp-plugin-install-list-table.php
index 7c54aefca1310..925c4fb9af7f3 100644
--- a/src/wp-admin/includes/class-wp-plugin-install-list-table.php
+++ b/src/wp-admin/includes/class-wp-plugin-install-list-table.php
@@ -636,7 +636,15 @@ public function display_rows() {
$incompatible_notice_message .= wp_update_php_annotation( '
', '', false );
}
} elseif ( ! $compatible_wp ) {
- $incompatible_notice_message .= __( 'This plugin does not work with your version of WordPress.' );
+ if ( ! empty( $requires_wp ) ) {
+ $incompatible_notice_message .= sprintf(
+ /* translators: %s: Minimum required WordPress version. */
+ __( 'This plugin requires WordPress %s or higher.' ),
+ $requires_wp
+ );
+ } else {
+ $incompatible_notice_message .= __( 'This plugin does not work with your version of WordPress.' );
+ }
if ( current_user_can( 'update_core' ) ) {
$incompatible_notice_message .= sprintf(
/* translators: %s: URL to WordPress Updates screen. */
diff --git a/src/wp-admin/includes/plugin-install.php b/src/wp-admin/includes/plugin-install.php
index dc232f1df7018..29f3a759fefbf 100644
--- a/src/wp-admin/includes/plugin-install.php
+++ b/src/wp-admin/includes/plugin-install.php
@@ -836,7 +836,15 @@ function install_plugin_information() {
)
);
} elseif ( ! $compatible_wp ) {
- $compatible_wp_notice_message = __( 'Error: This plugin requires a newer version of WordPress.' );
+ if ( ! empty( $requires_wp ) ) {
+ $compatible_wp_notice_message = sprintf(
+ /* translators: %s: Minimum required WordPress version. */
+ __( 'Error: This plugin requires WordPress %s or higher.' ),
+ $requires_wp
+ );
+ } else {
+ $compatible_wp_notice_message = __( 'Error: This plugin requires a newer version of WordPress.' );
+ }
if ( current_user_can( 'update_core' ) ) {
$compatible_wp_notice_message .= sprintf(
/* translators: %s: URL to WordPress Updates screen. */
diff --git a/tests/phpunit/tests/admin/wpPluginInstallListTable.php b/tests/phpunit/tests/admin/wpPluginInstallListTable.php
index 406c740a443f3..00a8fbb73c1e6 100644
--- a/tests/phpunit/tests/admin/wpPluginInstallListTable.php
+++ b/tests/phpunit/tests/admin/wpPluginInstallListTable.php
@@ -13,6 +13,7 @@ class Tests_Admin_wpPluginInstallListTable extends WP_UnitTestCase {
public function set_up() {
parent::set_up();
+ require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
$this->table = _get_list_table( 'WP_Plugin_Install_List_Table', array( 'screen' => 'plugin-install' ) );
}
@@ -24,4 +25,34 @@ public function set_up() {
public function test_get_views_should_return_no_views_by_default() {
$this->assertSame( array(), $this->table->get_views() );
}
+
+ /**
+ * @ticket 61211
+ *
+ * @covers WP_Plugin_Install_List_Table::display_rows
+ */
+ public function test_display_rows_incompatible_wp_displays_required_version() {
+ $this->table->items = array(
+ array(
+ 'slug' => 'incompatible-test-plugin',
+ 'name' => 'Incompatible Test Plugin',
+ 'version' => '1.0.0',
+ 'author' => 'Test Author',
+ 'requires' => '99.0',
+ 'requires_php' => '7.0',
+ 'last_updated' => '2026-01-01 00:00:00',
+ 'icons' => array( 'default' => 'http://example.org/icon.png' ),
+ 'short_description' => 'A test plugin.',
+ 'rating' => 100,
+ 'num_ratings' => 1,
+ 'active_installs' => 1000,
+ ),
+ );
+
+ ob_start();
+ $this->table->display_rows();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'This plugin requires WordPress 99.0 or higher.', $output );
+ }
}
From 35ffdc78379ec4f6b769b1ed28b48828a2a2097c Mon Sep 17 00:00:00 2001
From: kipmyk
Date: Sat, 25 Jul 2026 08:48:11 +0300
Subject: [PATCH 7/7] Plugins: Escape $requires_wp version in incompatibility
notices.
---
src/wp-admin/includes/class-wp-plugin-install-list-table.php | 2 +-
src/wp-admin/includes/plugin-install.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wp-admin/includes/class-wp-plugin-install-list-table.php b/src/wp-admin/includes/class-wp-plugin-install-list-table.php
index 925c4fb9af7f3..64a69c0f8ae8b 100644
--- a/src/wp-admin/includes/class-wp-plugin-install-list-table.php
+++ b/src/wp-admin/includes/class-wp-plugin-install-list-table.php
@@ -640,7 +640,7 @@ public function display_rows() {
$incompatible_notice_message .= sprintf(
/* translators: %s: Minimum required WordPress version. */
__( 'This plugin requires WordPress %s or higher.' ),
- $requires_wp
+ esc_html( $requires_wp )
);
} else {
$incompatible_notice_message .= __( 'This plugin does not work with your version of WordPress.' );
diff --git a/src/wp-admin/includes/plugin-install.php b/src/wp-admin/includes/plugin-install.php
index 29f3a759fefbf..f71cae6dbcb03 100644
--- a/src/wp-admin/includes/plugin-install.php
+++ b/src/wp-admin/includes/plugin-install.php
@@ -840,7 +840,7 @@ function install_plugin_information() {
$compatible_wp_notice_message = sprintf(
/* translators: %s: Minimum required WordPress version. */
__( 'Error: This plugin requires WordPress %s or higher.' ),
- $requires_wp
+ esc_html( $requires_wp )
);
} else {
$compatible_wp_notice_message = __( 'Error: This plugin requires a newer version of WordPress.' );