diff --git a/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php b/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php index b6900ca07..fae1568be 100644 --- a/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/Plugin_Header_Fields_Check.php @@ -372,22 +372,7 @@ public function run( Check_Result $result ) { } if ( ! empty( $plugin_header['RequiresPlugins'] ) ) { - if ( ! preg_match( '/^[a-z0-9-]+(?:,\s*[a-z0-9-]+)*$/', $plugin_header['RequiresPlugins'] ) ) { - $this->add_result_error_for_file( - $result, - sprintf( - /* translators: %s: plugin header field */ - __( 'The "%s" header in the plugin file must contain a comma-separated list of WordPress.org-formatted slugs.', 'plugin-check' ), - esc_html( $labels['RequiresPlugins'] ) - ), - 'plugin_header_invalid_requires_plugins', - $plugin_main_file, - 0, - 0, - '', - 7 - ); - } + $this->check_requires_plugins_header( $result, $plugin_header['RequiresPlugins'], $labels['RequiresPlugins'], $plugin_main_file ); } if ( empty( $plugin_header['License'] ) ) { @@ -536,6 +521,113 @@ public function run( Check_Result $result ) { } } + /** + * Validates each slug declared in the "Requires Plugins" header individually. + * + * Reports an error per malformed slug, and a warning per validly formatted + * slug that could not be found in the WordPress.org plugin directory. + * + * @since 2.1.0 + * + * @param Check_Result $result The check result to amend. + * @param string $requires_plugins Raw value of the "Requires Plugins" header. + * @param string $label Label of the "Requires Plugins" header. + * @param string $plugin_main_file Absolute path to the main plugin file. + */ + private function check_requires_plugins_header( Check_Result $result, string $requires_plugins, string $label, string $plugin_main_file ) { + $slugs = array_map( 'trim', explode( ',', $requires_plugins ) ); + + foreach ( $slugs as $slug ) { + if ( '' === $slug ) { + continue; + } + + if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) { + $this->add_result_error_for_file( + $result, + sprintf( + /* translators: 1: plugin header field, 2: dependency slug */ + __( 'The "%1$s" header in the plugin file contains "%2$s", which is not a valid WordPress.org-formatted slug.', 'plugin-check' ), + esc_html( $label ), + esc_html( $slug ) + ), + 'plugin_header_invalid_requires_plugins', + $plugin_main_file, + 0, + 0, + '', + 7 + ); + continue; + } + + $this->check_requires_plugins_slug_status( $result, $slug, $plugin_main_file ); + } + } + + /** + * Checks whether a single declared plugin dependency exists in the WordPress.org plugin directory. + * + * @since 2.1.0 + * + * @param Check_Result $result The check result to amend. + * @param string $slug The dependency's WordPress.org slug. + * @param string $plugin_main_file Absolute path to the main plugin file. + */ + private function check_requires_plugins_slug_status( Check_Result $result, string $slug, string $plugin_main_file ) { + $exists = $this->plugin_exists_in_directory( $slug ); + + // Unknown due to an unreachable API; skip rather than risk a false positive. + if ( null === $exists || $exists ) { + return; + } + + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: %s: dependency slug */ + __( 'The plugin declares "%s" as a required plugin, but it could not be found in the WordPress.org plugin directory.', 'plugin-check' ), + esc_html( $slug ) + ), + 'plugin_header_requires_plugins_not_in_directory', + $plugin_main_file, + 0, + 0, + '', + 3 + ); + } + + /** + * Checks whether a plugin slug exists in the WordPress.org plugin directory. + * + * @since 2.1.0 + * + * @param string $slug The plugin's WordPress.org slug. + * @return bool|null True if found, false if not found, null if the API could not be reached. + */ + private function plugin_exists_in_directory( string $slug ) { + $transient_key = 'wp_plugin_check_requires_plugin_' . $slug; + $exists = get_transient( $transient_key ); + + if ( false !== $exists ) { + return 'yes' === $exists; + } + + $response = wp_remote_get( "https://api.wordpress.org/plugins/info/1.0/{$slug}.json" ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return null; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $exists = isset( $body['slug'] ) && ! isset( $body['error'] ); + + set_transient( $transient_key, $exists ? 'yes' : 'no', DAY_IN_SECONDS ); + + return $exists; + } + /** * Parses the plugin contents to retrieve plugin's metadata. * diff --git a/tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php b/tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php new file mode 100644 index 000000000..54e3fa76c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-requires-plugins-status/load.php @@ -0,0 +1,17 @@ +assertCount( 1, wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_nonexistent_domain_path' ) ) ); if ( is_wp_version_compatible( '6.5' ) ) { - $this->assertCount( 1, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); + // Fixture declares "Example Plugin, OtherPlugin", neither of which is a valid slug. + $this->assertCount( 2, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); } } @@ -89,6 +90,9 @@ public function test_run_with_valid_requires_plugins_header() { * Requires Plugins: woocommerce, contact-form-7 */ + set_transient( 'wp_plugin_check_requires_plugin_woocommerce', 'yes' ); + set_transient( 'wp_plugin_check_requires_plugin_contact-form-7', 'yes' ); + $check = new Plugin_Header_Fields_Check(); $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-unfiltered-uploads-with-errors/load.php' ); $check_result = new Check_Result( $check_context ); @@ -97,6 +101,9 @@ public function test_run_with_valid_requires_plugins_header() { $errors = $check_result->get_errors(); + delete_transient( 'wp_plugin_check_requires_plugin_woocommerce' ); + delete_transient( 'wp_plugin_check_requires_plugin_contact-form-7' ); + if ( is_wp_version_compatible( '6.5' ) ) { $this->assertCount( 0, wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); } else { @@ -105,6 +112,88 @@ public function test_run_with_valid_requires_plugins_header() { } } + public function test_run_with_multiple_invalid_requires_plugins_slugs_reported_individually() { + /* + * Test plugin has following header, with both slugs invalid. + * Requires Plugins: Example Plugin, OtherPlugin + */ + + $check = new Plugin_Header_Fields_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-header-fields-with-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + if ( ! is_wp_version_compatible( '6.5' ) ) { + $this->assertTrue( true ); + return; + } + + $messages = wp_list_pluck( wp_list_filter( $errors['load.php'][0][0], array( 'code' => 'plugin_header_invalid_requires_plugins' ) ), 'message' ); + + $this->assertCount( 2, $messages, 'Each invalid slug should be reported as its own error.' ); + + $example_plugin_messages = array_filter( + $messages, + static function ( $message ) { + return str_contains( $message, 'Example Plugin' ); + } + ); + $other_plugin_messages = array_filter( + $messages, + static function ( $message ) { + return str_contains( $message, 'OtherPlugin' ); + } + ); + + $this->assertCount( 1, $example_plugin_messages, 'Exactly one error should name "Example Plugin".' ); + $this->assertStringNotContainsString( 'OtherPlugin', reset( $example_plugin_messages ) ); + + $this->assertCount( 1, $other_plugin_messages, 'Exactly one error should name "OtherPlugin".' ); + $this->assertStringNotContainsString( 'Example Plugin', reset( $other_plugin_messages ) ); + } + + public function test_run_with_requires_plugins_dependency_status() { + /* + * Test plugin has following header. + * Requires Plugins: plugin-check, hello-dolly, not-installed-plugin + * + * "plugin-check" and "hello-dolly" are seeded as published in the + * WordPress.org plugin directory; "not-installed-plugin" is seeded + * as not found there. + */ + + set_transient( 'wp_plugin_check_requires_plugin_plugin-check', 'yes' ); + set_transient( 'wp_plugin_check_requires_plugin_hello-dolly', 'yes' ); + set_transient( 'wp_plugin_check_requires_plugin_not-installed-plugin', 'no' ); + + $check = new Plugin_Header_Fields_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-requires-plugins-status/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + delete_transient( 'wp_plugin_check_requires_plugin_plugin-check' ); + delete_transient( 'wp_plugin_check_requires_plugin_hello-dolly' ); + delete_transient( 'wp_plugin_check_requires_plugin_not-installed-plugin' ); + + if ( ! is_wp_version_compatible( '6.5' ) ) { + $this->assertTrue( true ); + return; + } + + $this->assertCount( 0, wp_list_filter( $errors['load.php'][0][0] ?? array(), array( 'code' => 'plugin_header_invalid_requires_plugins' ) ) ); + + $not_in_directory_items = wp_list_filter( $warnings['load.php'][0][0], array( 'code' => 'plugin_header_requires_plugins_not_in_directory' ) ); + $this->assertCount( 1, $not_in_directory_items, 'Only the dependency missing from the directory should get a warning.' ); + $this->assertStringContainsString( 'not-installed-plugin', reset( $not_in_directory_items )['message'] ); + } + public function test_run_with_mismatched_tested_up_to() { $check = new Plugin_Header_Fields_Check(); $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-tested-up-to-mismatch/load.php' );