From 1e238ce1eea244535837d2805867777f990bee85 Mon Sep 17 00:00:00 2001 From: westonruter Date: Sat, 4 Jul 2026 16:52:08 +0000 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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 59256178aab19dd1fa277068c228fde28a646f03 Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Fri, 17 Jul 2026 13:54:14 +0530 Subject: [PATCH 6/6] Media: Keep the modal search field visible when plugins add filters. When a plugin adds extra filters to the media modal toolbar, the fixed height of the toolbar causes it to scroll, and the search field could scroll out of view. Pin the search field to the top of the scrollable toolbar so it stays visible while the filters remain scrollable. This leaves the toolbar height and content offsets unchanged, so the behavior is consistent across viewport sizes and the standalone media grid screen is unaffected. See #64321. --- src/wp-includes/css/media-views.css | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/wp-includes/css/media-views.css b/src/wp-includes/css/media-views.css index 835b04a8bf6bf..2c63e54064d1e 100644 --- a/src/wp-includes/css/media-views.css +++ b/src/wp-includes/css/media-views.css @@ -1290,6 +1290,17 @@ select#media-attachment-date-filters { max-width: calc( 33% - 20px ); } +/* + * Keep the search field visible in the media modal when plugins add extra + * filters that make the toolbar scroll. Pinning the search field to the top + * of the scrollable toolbar leaves the filters scrollable while the search + * field stays in view. See #64321. + */ +.media-modal .attachments-browser .media-toolbar-primary { + position: sticky; + top: 0; +} + .mode-grid .attachments-browser .media-toolbar-primary { display: flex; align-items: center;