From 0a06f412e2d38ce1e2b3b820408c2b71d144aaf5 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Wed, 25 Mar 2026 01:18:55 +0100 Subject: [PATCH 01/26] Fixed rest_handle_doing_it_wrong to manage debug messages properly - part 2 --- src/wp-includes/rest-api.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index c524f9e22a12f..73b2d7ef0e600 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -767,7 +767,7 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { * @param string|null $version The version of WordPress where the message was added. */ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { - if ( ! WP_DEBUG || headers_sent() ) { + if ( ! WP_DEBUG ) { return; } @@ -781,7 +781,15 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { $string = sprintf( $string, $function_name, $message ); } - header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); + if ( WP_DEBUG_LOG ) { + error_log( + sprintf( + 'REST API - Function %1$s was called incorrectly. %2$s', + $function_name, + wp_strip_all_tags( $message ) + ) + ); + } } /** From 6c3e353cf8125c7ec8a41db412924075982b9b48 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Wed, 25 Mar 2026 18:54:48 +0100 Subject: [PATCH 02/26] Fixed rest_handle_doing_it_wrong: added headers check --- src/wp-includes/rest-api.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 73b2d7ef0e600..30fe8c3aeefe3 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -781,6 +781,9 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { $string = sprintf( $string, $function_name, $message ); } + if ( ! headers_sent() ) { + header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); + } if ( WP_DEBUG_LOG ) { error_log( sprintf( From 610ac74f62f460309870357b0bbaa40c92ee5af1 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Thu, 26 Mar 2026 10:21:39 +0100 Subject: [PATCH 03/26] REST API: Use with 'PHP Notice:' prefix in rest_handle_doing_it_wrong() error log --- src/wp-includes/rest-api.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 30fe8c3aeefe3..492d9244e7a03 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -785,13 +785,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); } if ( WP_DEBUG_LOG ) { - error_log( - sprintf( - 'REST API - Function %1$s was called incorrectly. %2$s', - $function_name, - wp_strip_all_tags( $message ) - ) - ); + error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) ); } } From 8bb3815aea7ef398c630bc4430a2c8eb85674570 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Mon, 6 Apr 2026 18:00:48 +0200 Subject: [PATCH 04/26] REST API: Add caller location to rest_handle_doing_it_wrong() error log Use debug_backtrace() to find and append the first plugin or theme file responsible for the incorrect usage to the error_log() message. --- src/wp-includes/rest-api.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 492d9244e7a03..99147bf8338d2 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -784,8 +784,18 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { if ( ! headers_sent() ) { header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); } + if ( WP_DEBUG_LOG ) { - error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) ); + $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); + $caller = ''; + // Find the first caller outside of WordPress core (plugin or theme). + foreach ( $backtrace as $frame ) { + if ( isset( $frame['file'] ) && str_contains( $frame['file'], WP_CONTENT_DIR ) ) { + $caller = ' in ' . $frame['file'] . ' on line ' . $frame['line']; + break; + } + } + error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . $caller ); } } From 0a98d181fed269558628919c9c002dadfe02e22e Mon Sep 17 00:00:00 2001 From: Claudio Date: Fri, 15 May 2026 09:22:52 +0200 Subject: [PATCH 05/26] Update src/wp-includes/rest-api.php REST API: Guard against missing line in backtrace frame Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 06e45ffd396d7..d7e8e780db5b4 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -784,7 +784,10 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { // Find the first caller outside of WordPress core (plugin or theme). foreach ( $backtrace as $frame ) { if ( isset( $frame['file'] ) && str_contains( $frame['file'], WP_CONTENT_DIR ) ) { - $caller = ' in ' . $frame['file'] . ' on line ' . $frame['line']; + $caller = ' in ' . $frame['file']; + if ( isset( $frame['line'] ) ) { + $caller .= ' on line ' . $frame['line']; + } break; } } From 922b7358ed546a9d9e72ad252dfea4b5c02c5f44 Mon Sep 17 00:00:00 2001 From: Claudio Date: Fri, 15 May 2026 09:23:11 +0200 Subject: [PATCH 06/26] Update src/wp-includes/rest-api.php REST API: Address review feedback on doing_it_wrong logger Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index d7e8e780db5b4..1410fcbe1669c 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -783,7 +783,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { $caller = ''; // Find the first caller outside of WordPress core (plugin or theme). foreach ( $backtrace as $frame ) { - if ( isset( $frame['file'] ) && str_contains( $frame['file'], WP_CONTENT_DIR ) ) { + if ( isset( $frame['file'] ) && str_starts_with( $frame['file'], WP_CONTENT_DIR ) ) { $caller = ' in ' . $frame['file']; if ( isset( $frame['line'] ) ) { $caller .= ' on line ' . $frame['line']; From 3829aed76e1ee1665396f3621aee41c20e98ac42 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sat, 6 Jun 2026 16:17:27 +0200 Subject: [PATCH 07/26] Update src/wp-includes/rest-api.php We don't need wp_strip_all_tags, removing it Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 1410fcbe1669c..03b6efb575311 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -791,7 +791,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { break; } } - error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . $caller ); + error_log( 'PHP Notice: ' . $string . $caller ); } } From 9b5587582c390c986d2db286cd8df9815363aed3 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sat, 6 Jun 2026 16:18:29 +0200 Subject: [PATCH 08/26] Update src/wp-includes/rest-api.php This should ensure the notices only get logged out if such logs are desired. Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 03b6efb575311..06f7d9a22b49a 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -778,7 +778,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); } - if ( WP_DEBUG_LOG ) { + if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_NOTICE ) ) { $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); $caller = ''; // Find the first caller outside of WordPress core (plugin or theme). From 5777f1a22f83e6d6a1444720f35baa22c279f1ae Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Sat, 6 Jun 2026 18:58:20 +0200 Subject: [PATCH 09/26] REST API: Fix path comparison and backtrace depth in rest_handle_doing_it_wrong() - Normalize paths via wp_normalize_path() to fix Windows backslash mismatch - Add trailingslashit() to prevent false matches on sibling directories - Limit debug_backtrace() to 20 frames - After testing, the log output contains raw HTML tags (e.g. ) I'd lean toward keeping wp_strip_all_tags() for cleaner plain-text log output --- src/wp-includes/rest-api.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 06f7d9a22b49a..6e8aae0e964c7 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -779,11 +779,12 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { } if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_NOTICE ) ) { - $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); - $caller = ''; + $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); + $caller = ''; + $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); // Find the first caller outside of WordPress core (plugin or theme). foreach ( $backtrace as $frame ) { - if ( isset( $frame['file'] ) && str_starts_with( $frame['file'], WP_CONTENT_DIR ) ) { + if ( isset( $frame['file'] ) && str_starts_with( wp_normalize_path( $frame['file'] ), $normalized_content_dir ) ) { $caller = ' in ' . $frame['file']; if ( isset( $frame['line'] ) ) { $caller .= ' on line ' . $frame['line']; @@ -791,7 +792,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { break; } } - error_log( 'PHP Notice: ' . $string . $caller ); + error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . $caller ); } } From a4076dbf8ab19ef4697fbcb2689d6caedda65341 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Wed, 10 Jun 2026 16:40:54 +0200 Subject: [PATCH 10/26] REST API: Extend debug logging to deprecated function and argument handlers, add caller location --- src/wp-includes/rest-api.php | 83 ++++++++++++++++++++++++++++-------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index f49ab0ac944b7..1943cfbaff9c4 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -702,6 +702,54 @@ function rest_ensure_response( $response ) { return new WP_REST_Response( $response ); } +/** + * Returns a formatted caller location string for REST API debug log entries. + * + * Searches the call stack for the first frame whose file lives inside WP_CONTENT_DIR, + * identifying the plugin or theme that triggered the notice. Used by the REST API + * debug handlers to append actionable location info to error_log() output. + * + * @since 7.1.0 + * @access private + * + * @return string Formatted string such as ' called from my_func() in /path/plugin.php on line 8', + * or ' called from (anonymous function) in /path/plugin.php on line 8' for closures, + * or empty string when no plugin/theme frame is found in the call stack. + */ +function _rest_get_debug_backtrace_caller() { + $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); + $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); + + foreach ( $backtrace as $i => $frame ) { + if ( ! isset( $frame['file'] ) || ! str_starts_with( wp_normalize_path( $frame['file'] ), $normalized_content_dir ) ) { + continue; + } + + $location = ' in ' . wp_normalize_path( $frame['file'] ); + if ( isset( $frame['line'] ) ) { + $location .= ' on line ' . $frame['line']; + } + + /* + * The next frame holds the function that contains the offending call. + * PHP represents closures as '{closure}' (< 8.4) or '{closure:file:line}' (>= 8.4), + * both starting with '{'. These are replaced with a readable label. + */ + $next = $backtrace[ $i + 1 ] ?? array(); + if ( ! empty( $next['function'] ) ) { + if ( str_starts_with( $next['function'], '{' ) ) { + return ' called from (anonymous function)' . $location; + } + $caller_func = isset( $next['class'] ) ? $next['class'] . ( $next['type'] ?? '::' ) . $next['function'] : $next['function']; + return ' called from ' . $caller_func . '()' . $location; + } + + return $location; + } + + return ''; +} + /** * Handles _deprecated_function() errors. * @@ -712,7 +760,7 @@ function rest_ensure_response( $response ) { * @param string $version Version. */ function rest_handle_deprecated_function( $function_name, $replacement, $version ) { - if ( ! WP_DEBUG || headers_sent() ) { + if ( ! WP_DEBUG ) { return; } if ( ! empty( $replacement ) ) { @@ -723,7 +771,13 @@ function rest_handle_deprecated_function( $function_name, $replacement, $version $string = sprintf( __( '%1$s (since %2$s; no alternative available)' ), $function_name, $version ); } - header( sprintf( 'X-WP-DeprecatedFunction: %s', $string ) ); + if ( ! headers_sent() ) { + header( sprintf( 'X-WP-DeprecatedFunction: %s', $string ) ); + } + + if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_DEPRECATED ) ) { + error_log( 'PHP Deprecated: ' . wp_strip_all_tags( $string ) . _rest_get_debug_backtrace_caller() ); + } } /** @@ -736,7 +790,7 @@ function rest_handle_deprecated_function( $function_name, $replacement, $version * @param string $version Version. */ function rest_handle_deprecated_argument( $function_name, $message, $version ) { - if ( ! WP_DEBUG || headers_sent() ) { + if ( ! WP_DEBUG ) { return; } if ( $message ) { @@ -747,7 +801,13 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { $string = sprintf( __( '%1$s (since %2$s; no alternative available)' ), $function_name, $version ); } - header( sprintf( 'X-WP-DeprecatedParam: %s', $string ) ); + if ( ! headers_sent() ) { + header( sprintf( 'X-WP-DeprecatedParam: %s', $string ) ); + } + + if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_DEPRECATED ) ) { + error_log( 'PHP Deprecated: ' . wp_strip_all_tags( $string ) . _rest_get_debug_backtrace_caller() ); + } } /** @@ -779,20 +839,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { } if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_NOTICE ) ) { - $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); - $caller = ''; - $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); - // Find the first caller outside of WordPress core (plugin or theme). - foreach ( $backtrace as $frame ) { - if ( isset( $frame['file'] ) && str_starts_with( wp_normalize_path( $frame['file'] ), $normalized_content_dir ) ) { - $caller = ' in ' . $frame['file']; - if ( isset( $frame['line'] ) ) { - $caller .= ' on line ' . $frame['line']; - } - break; - } - } - error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . $caller ); + error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . _rest_get_debug_backtrace_caller() ); } } From 8329da1b95631f9faf54b9c9da2c15106dab7c07 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Wed, 10 Jun 2026 18:45:27 +0200 Subject: [PATCH 11/26] REST API: Fix case-insensitive path comparison in _rest_get_debug_backtrace_caller() --- src/wp-includes/rest-api.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 1943cfbaff9c4..04eb17fb0f31e 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -718,14 +718,19 @@ function rest_ensure_response( $response ) { */ function _rest_get_debug_backtrace_caller() { $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); - $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); + $normalized_content_dir = strtolower( trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) ); foreach ( $backtrace as $i => $frame ) { - if ( ! isset( $frame['file'] ) || ! str_starts_with( wp_normalize_path( $frame['file'] ), $normalized_content_dir ) ) { + if ( ! isset( $frame['file'] ) ) { continue; } - $location = ' in ' . wp_normalize_path( $frame['file'] ); + $normalized_file = wp_normalize_path( $frame['file'] ); + if ( ! str_starts_with( strtolower( $normalized_file ), $normalized_content_dir ) ) { + continue; + } + + $location = ' in ' . $normalized_file; if ( isset( $frame['line'] ) ) { $location .= ' on line ' . $frame['line']; } From 3b7826dc8b86f7c7c0f215fdc0fa3f1a243a4c3b Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 14 Jun 2026 12:03:42 +0200 Subject: [PATCH 12/26] Update src/wp-includes/rest-api.php Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 04eb17fb0f31e..cdb722ca124f7 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -716,7 +716,7 @@ function rest_ensure_response( $response ) { * or ' called from (anonymous function) in /path/plugin.php on line 8' for closures, * or empty string when no plugin/theme frame is found in the call stack. */ -function _rest_get_debug_backtrace_caller() { +function _rest_get_debug_backtrace_caller(): string { $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); $normalized_content_dir = strtolower( trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) ); From b714964815af541a0c67d9cb0cc03dfd3612b816 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 14 Jun 2026 12:05:55 +0200 Subject: [PATCH 13/26] Update src/wp-includes/rest-api.php Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index cdb722ca124f7..e8f2df68b566d 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -745,7 +745,10 @@ function _rest_get_debug_backtrace_caller(): string { if ( str_starts_with( $next['function'], '{' ) ) { return ' called from (anonymous function)' . $location; } - $caller_func = isset( $next['class'] ) ? $next['class'] . ( $next['type'] ?? '::' ) . $next['function'] : $next['function']; + $caller_func = $next['function']; + if ( isset( $next['class'] ) ) { + $caller_func = $next['class'] . $next['type'] . $caller_func; + } return ' called from ' . $caller_func . '()' . $location; } From 546dc0738faa5c37dff99c5c7b4964fc54dd623c Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 14 Jun 2026 12:06:08 +0200 Subject: [PATCH 14/26] Update src/wp-includes/rest-api.php Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index e8f2df68b566d..8a0a736790230 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -740,8 +740,8 @@ function _rest_get_debug_backtrace_caller(): string { * PHP represents closures as '{closure}' (< 8.4) or '{closure:file:line}' (>= 8.4), * both starting with '{'. These are replaced with a readable label. */ - $next = $backtrace[ $i + 1 ] ?? array(); - if ( ! empty( $next['function'] ) ) { + $next = $backtrace[ $i + 1 ] ?? null; + if ( $next ) { if ( str_starts_with( $next['function'], '{' ) ) { return ' called from (anonymous function)' . $location; } From 8fce388ced2d43634d28ae298e8afbb2018bc790 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 14 Jun 2026 12:06:22 +0200 Subject: [PATCH 15/26] Update src/wp-includes/rest-api.php Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 8a0a736790230..547b5d106e71e 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -717,7 +717,7 @@ function rest_ensure_response( $response ) { * or empty string when no plugin/theme frame is found in the call stack. */ function _rest_get_debug_backtrace_caller(): string { - $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 20 ); + $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); $normalized_content_dir = strtolower( trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) ); foreach ( $backtrace as $i => $frame ) { From d88a269720d2d9e425b8944dfc4e720e4470c635 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 14 Jun 2026 12:06:44 +0200 Subject: [PATCH 16/26] Update src/wp-includes/rest-api.php Co-authored-by: Weston Ruter --- src/wp-includes/rest-api.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 547b5d106e71e..60aa7cff04e95 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -745,6 +745,15 @@ function _rest_get_debug_backtrace_caller(): string { if ( str_starts_with( $next['function'], '{' ) ) { return ' called from (anonymous function)' . $location; } + + /* + * A call made from a file's global scope has no enclosing function; PHP + * reports the include/require pseudo-function that loaded the file instead. + * In that case only the location is meaningful. + */ + if ( in_array( $next['function'], array( 'require', 'require_once', 'include', 'include_once' ), true ) ) { + return $location; + } $caller_func = $next['function']; if ( isset( $next['class'] ) ) { $caller_func = $next['class'] . $next['type'] . $caller_func; From 2f6ed990c291973411c611a061ea370a6623b717 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Sun, 14 Jun 2026 17:09:12 +0200 Subject: [PATCH 17/26] REST API: Remove strtolower() from path comparison in _rest_get_debug_backtrace_caller() --- src/wp-includes/rest-api.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 60aa7cff04e95..21d07d5b34ce9 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -718,7 +718,7 @@ function rest_ensure_response( $response ) { */ function _rest_get_debug_backtrace_caller(): string { $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); - $normalized_content_dir = strtolower( trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ) ); + $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); foreach ( $backtrace as $i => $frame ) { if ( ! isset( $frame['file'] ) ) { @@ -726,7 +726,7 @@ function _rest_get_debug_backtrace_caller(): string { } $normalized_file = wp_normalize_path( $frame['file'] ); - if ( ! str_starts_with( strtolower( $normalized_file ), $normalized_content_dir ) ) { + if ( ! str_starts_with( $normalized_file, $normalized_content_dir ) ) { continue; } @@ -745,7 +745,7 @@ function _rest_get_debug_backtrace_caller(): string { if ( str_starts_with( $next['function'], '{' ) ) { return ' called from (anonymous function)' . $location; } - + /* * A call made from a file's global scope has no enclosing function; PHP * reports the include/require pseudo-function that loaded the file instead. From 1e238ce1eea244535837d2805867777f990bee85 Mon Sep 17 00:00:00 2001 From: westonruter Date: Sat, 4 Jul 2026 16:52:08 +0000 Subject: [PATCH 18/26] 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 19/26] 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 20/26] 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 21/26] 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 22/26] 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 eca66449c8b1b8f9d3d2d67ad2cf41360598f9e8 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 2 Aug 2026 11:38:28 +0200 Subject: [PATCH 23/26] Update src/wp-includes/rest-api.php ok, fixed Co-authored-by: Aki Hamano <54422211+t-hamano@users.noreply.github.com> --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 387a1f6731d43..2cc21059e273d 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -733,7 +733,7 @@ function rest_ensure_response( $response ) { * or ' called from (anonymous function) in /path/plugin.php on line 8' for closures, * or empty string when no plugin/theme frame is found in the call stack. */ -function _rest_get_debug_backtrace_caller(): string { +function _wp_rest_get_debug_backtrace_caller(): string { $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); From 687db29e666ab22ff0ad410078474809147469f3 Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Sun, 2 Aug 2026 12:20:09 +0200 Subject: [PATCH 24/26] REST API: Prefix the REST debug backtrace helper and harden caller detection. --- src/wp-includes/rest-api.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 2cc21059e273d..306bcc18037c6 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -759,6 +759,10 @@ function _wp_rest_get_debug_backtrace_caller(): string { */ $next = $backtrace[ $i + 1 ] ?? null; if ( $next ) { + if ( empty( $next['function'] ) ) { + return $location; + } + if ( str_starts_with( $next['function'], '{' ) ) { return ' called from (anonymous function)' . $location; } @@ -810,7 +814,7 @@ function rest_handle_deprecated_function( $function_name, $replacement, $version } if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_DEPRECATED ) ) { - error_log( 'PHP Deprecated: ' . wp_strip_all_tags( $string ) . _rest_get_debug_backtrace_caller() ); + error_log( 'PHP Deprecated: ' . wp_strip_all_tags( $string ) . _wp_rest_get_debug_backtrace_caller() ); } } @@ -840,7 +844,7 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { } if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_DEPRECATED ) ) { - error_log( 'PHP Deprecated: ' . wp_strip_all_tags( $string ) . _rest_get_debug_backtrace_caller() ); + error_log( 'PHP Deprecated: ' . wp_strip_all_tags( $string ) . _wp_rest_get_debug_backtrace_caller() ); } } @@ -873,7 +877,7 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { } if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_NOTICE ) ) { - error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . _rest_get_debug_backtrace_caller() ); + error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . _wp_rest_get_debug_backtrace_caller() ); } } From 16326f676da7bc67d053f8a16f55215979c203ba Mon Sep 17 00:00:00 2001 From: ilclaudio Date: Sun, 2 Aug 2026 12:30:38 +0200 Subject: [PATCH 25/26] Changed milestone as required by the reviewer --- src/wp-includes/rest-api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 306bcc18037c6..a9d9b62fedc0a 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -726,7 +726,7 @@ function rest_ensure_response( $response ) { * identifying the plugin or theme that triggered the notice. Used by the REST API * debug handlers to append actionable location info to error_log() output. * - * @since 7.1.0 + * @since 7.2.0 * @access private * * @return string Formatted string such as ' called from my_func() in /path/plugin.php on line 8', From 2f9f38f06f766d3cb670c20a8a8aa2da2560a57a Mon Sep 17 00:00:00 2001 From: rvpatel Date: Mon, 19 Oct 2026 15:38:17 +0530 Subject: [PATCH 26/26] Tests: Add coverage for `_wp_rest_get_debug_backtrace_caller()`. Covers the caller reported for a named function, an instance method, a static method, a closure, and a call from a file's global scope; that the innermost frame inside WP_CONTENT_DIR is used; that calls from outside WP_CONTENT_DIR report nothing; and that a sibling directory sharing the content directory's name prefix, such as wp-content-backup, is not treated as part of it. Each test calls the function from a fixture file written inside WP_CONTENT_DIR, which is removed afterwards. See #64260. --- .../wpRestGetDebugBacktraceCaller.php | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 tests/phpunit/tests/rest-api/wpRestGetDebugBacktraceCaller.php diff --git a/tests/phpunit/tests/rest-api/wpRestGetDebugBacktraceCaller.php b/tests/phpunit/tests/rest-api/wpRestGetDebugBacktraceCaller.php new file mode 100644 index 0000000000000..5a582dceaf679 --- /dev/null +++ b/tests/phpunit/tests/rest-api/wpRestGetDebugBacktraceCaller.php @@ -0,0 +1,253 @@ + + */ + private $fixture_dirs = array(); + + public function tear_down() { + foreach ( $this->fixture_files as $file ) { + if ( file_exists( $file ) ) { + unlink( $file ); + } + } + foreach ( array_reverse( $this->fixture_dirs ) as list( $dir, $top ) ) { + while ( is_dir( $dir ) ) { + rmdir( $dir ); + if ( $dir === $top ) { + break; + } + $dir = dirname( $dir ); + } + } + + parent::tear_down(); + } + + /** + * Writes a PHP fixture file, creating its directory if needed. + * + * @param string $dir Directory to write the file to. + * @param string[] $lines Lines of the file, without the opening PHP tag. + * @return string Path of the written file. + */ + private function write_fixture( $dir, array $lines ) { + if ( ! is_dir( $dir ) ) { + $top = $dir; + while ( ! is_dir( dirname( $top ) ) ) { + $top = dirname( $top ); + } + mkdir( $dir, 0777, true ); + $this->fixture_dirs[] = array( $dir, $top ); + } + + $file = $dir . '/' . uniqid( 'fixture-', true ) . '.php'; + file_put_contents( $file, "fixture_files[] = $file; + + return $file; + } + + /** + * Gets the directory for fixtures inside WP_CONTENT_DIR. + * + * @return string Directory path. + */ + private function content_dir() { + return WP_CONTENT_DIR . '/plugins/wp-rest-backtrace-fixture'; + } + + /** + * Tests that a call made outside WP_CONTENT_DIR has no caller to report. + * + * @ticket 64260 + */ + public function test_should_return_empty_string_without_a_content_dir_frame() { + $this->assertSame( '', _wp_rest_get_debug_backtrace_caller() ); + } + + /** + * Tests that a call from a named function reports the function, file and line. + * + * @ticket 64260 + */ + public function test_should_report_the_calling_function() { + $function = 'wp_rest_backtrace_fixture_' . wp_rand(); + $file = $this->write_fixture( + $this->content_dir(), + array( + "function {$function}() {", + ' return _wp_rest_get_debug_backtrace_caller();', + '}', + ) + ); + require $file; + + $this->assertSame( + ' called from ' . $function . '() in ' . wp_normalize_path( $file ) . ' on line 3', + $function() + ); + } + + /** + * Tests that a call from a method reports the class and the call type. + * + * @ticket 64260 + * + * @dataProvider data_method_calls + * + * @param string $call_type How the method is called: '->' or '::'. + */ + public function test_should_report_the_calling_method( $call_type ) { + $class = 'WP_REST_Backtrace_Fixture_' . wp_rand(); + $file = $this->write_fixture( + $this->content_dir(), + array( + "class {$class} {", + ' public function instance_method() {', + ' return _wp_rest_get_debug_backtrace_caller();', + ' }', + ' public static function static_method() {', + ' return _wp_rest_get_debug_backtrace_caller();', + ' }', + '}', + ) + ); + require $file; + + if ( '->' === $call_type ) { + $result = ( new $class() )->instance_method(); + $expected = ' called from ' . $class . '->instance_method() in ' . wp_normalize_path( $file ) . ' on line 4'; + } else { + $result = $class::static_method(); + $expected = ' called from ' . $class . '::static_method() in ' . wp_normalize_path( $file ) . ' on line 7'; + } + + $this->assertSame( $expected, $result ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_method_calls() { + return array( + 'instance method' => array( '->' ), + 'static method' => array( '::' ), + ); + } + + /** + * Tests that a call from a closure is reported as an anonymous function. + * + * @ticket 64260 + */ + public function test_should_report_a_closure_as_an_anonymous_function() { + $file = $this->write_fixture( + $this->content_dir(), + array( + 'return function () {', + ' return _wp_rest_get_debug_backtrace_caller();', + '};', + ) + ); + $closure = require $file; + + $this->assertSame( + ' called from (anonymous function) in ' . wp_normalize_path( $file ) . ' on line 3', + $closure() + ); + } + + /** + * Tests that a call from a file's global scope reports only the location, + * not the include or require that loaded the file. + * + * @ticket 64260 + */ + public function test_should_report_only_the_location_for_a_global_scope_call() { + $file = $this->write_fixture( + $this->content_dir(), + array( + 'return _wp_rest_get_debug_backtrace_caller();', + ) + ); + + $this->assertSame( + ' in ' . wp_normalize_path( $file ) . ' on line 2', + require $file + ); + } + + /** + * Tests that a directory whose name only starts with the content directory's + * name, such as wp-content-backup, is not treated as part of it. + * + * @ticket 64260 + */ + public function test_should_ignore_a_sibling_directory_sharing_the_content_dir_prefix() { + $function = 'wp_rest_backtrace_fixture_' . wp_rand(); + $file = $this->write_fixture( + untrailingslashit( WP_CONTENT_DIR ) . '-backup/plugins/wp-rest-backtrace-fixture', + array( + "function {$function}() {", + ' return _wp_rest_get_debug_backtrace_caller();', + '}', + ) + ); + require $file; + + $this->assertSame( '', $function() ); + } + + /** + * Tests that the frame closest to the call is reported when several frames + * are inside WP_CONTENT_DIR, such as a plugin calling a helper in another plugin. + * + * @ticket 64260 + */ + public function test_should_report_the_innermost_content_dir_frame() { + $inner = 'wp_rest_backtrace_fixture_inner_' . wp_rand(); + $outer = 'wp_rest_backtrace_fixture_outer_' . wp_rand(); + $file = $this->write_fixture( + $this->content_dir(), + array( + "function {$inner}() {", + ' return _wp_rest_get_debug_backtrace_caller();', + '}', + "function {$outer}() {", + " return {$inner}();", + '}', + ) + ); + require $file; + + $this->assertSame( + ' called from ' . $inner . '() in ' . wp_normalize_path( $file ) . ' on line 3', + $outer() + ); + } +}