diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 4ec8f119f36e0..734ec4e8d235f 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -729,6 +729,75 @@ 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.2.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 _wp_rest_get_debug_backtrace_caller(): string { + $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); + $normalized_content_dir = trailingslashit( wp_normalize_path( WP_CONTENT_DIR ) ); + + foreach ( $backtrace as $i => $frame ) { + if ( ! isset( $frame['file'] ) ) { + continue; + } + + $normalized_file = wp_normalize_path( $frame['file'] ); + if ( ! str_starts_with( $normalized_file, $normalized_content_dir ) ) { + continue; + } + + $location = ' in ' . $normalized_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 ] ?? null; + if ( $next ) { + if ( empty( $next['function'] ) ) { + return $location; + } + + 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; + } + return ' called from ' . $caller_func . '()' . $location; + } + + return $location; + } + + return ''; +} + /** * Handles _deprecated_function() errors. * @@ -739,7 +808,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 ) ) { @@ -750,7 +819,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 ) . _wp_rest_get_debug_backtrace_caller() ); + } } /** @@ -763,7 +838,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 ) { @@ -774,7 +849,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 ) . _wp_rest_get_debug_backtrace_caller() ); + } } /** @@ -787,7 +868,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; } @@ -801,7 +882,13 @@ function rest_handle_doing_it_wrong( $function_name, $message, $version ) { $string = sprintf( $string, $function_name, $message ); } - header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); + if ( ! headers_sent() ) { + header( sprintf( 'X-WP-DoingItWrong: %s', $string ) ); + } + + if ( WP_DEBUG_LOG && ( error_reporting() & E_USER_NOTICE ) ) { + error_log( 'PHP Notice: ' . wp_strip_all_tags( $string ) . _wp_rest_get_debug_backtrace_caller() ); + } } /** 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() + ); + } +}