diff --git a/includes/Checker/Abstract_Check_Runner.php b/includes/Checker/Abstract_Check_Runner.php index a082cd4a8..834e2da5b 100644 --- a/includes/Checker/Abstract_Check_Runner.php +++ b/includes/Checker/Abstract_Check_Runner.php @@ -501,6 +501,7 @@ private function register_checks() { 'enqueued_styles_scope' => new Checks\Enqueued_Styles_Scope_Check(), 'localhost' => new Checks\Localhost_Check(), 'no_unfiltered_uploads' => new Checks\No_Unfiltered_Uploads_Check(), + 'trademarks' => new Checks\Trademarks_Check(), ) ); diff --git a/includes/Checker/Checks/Trademarks_Check.php b/includes/Checker/Checks/Trademarks_Check.php new file mode 100644 index 000000000..cc9a7a0b5 --- /dev/null +++ b/includes/Checker/Checks/Trademarks_Check.php @@ -0,0 +1,526 @@ +flags = $flags; + } + + /** + * Gets the categories for the check. + * + * Every check must have at least one category. + * + * @since n.e.x.t + * + * @return array The categories for the check. + */ + public function get_categories() { + return array( Check_Categories::CATEGORY_PLUGIN_REPO ); + } + + /** + * Check for trademarks. + * + * @since n.e.x.t + * + * @param Check_Result $result The Check Result to amend. + * @param array $files Array of plugin files. + */ + protected function check_files( Check_Result $result, array $files ) { + + // Check the trademarks in readme file plugin name. + if ( $this->flags & self::TYPE_README ) { + $this->check_for_name_in_readme( $result, $files ); + } + + // Check the trademarks in plugin name. + if ( $this->flags & self::TYPE_NAME ) { + $this->check_for_name_in_main_file( $result ); + } + + // Check the trademarks in plugin slug. + if ( $this->flags & self::TYPE_SLUG ) { + $this->check_for_slug( $result ); + } + } + + /** + * Checks the trademarks in readme file plugin name. + * + * @since n.e.x.t + * + * @param Check_Result $result The Check Result to amend. + * @param array $files Array of plugin files. + */ + private function check_for_name_in_readme( Check_Result $result, array $files ) { + $plugin_relative_path = $result->plugin()->path(); + + // Find the readme file. + $readme_list = self::filter_files_by_regex( $files, '/readme\.(txt|md)$/i' ); + + // Filter the readme files located at root. + $potential_readme_files = array_filter( + $readme_list, + function ( $file ) use ( $plugin_relative_path ) { + $file = str_replace( $plugin_relative_path, '', $file ); + return ! str_contains( $file, '/' ); + } + ); + + // If the readme file does not exist, then skip test. + if ( empty( $potential_readme_files ) ) { + return; + } + + // Find the .txt versions of the readme files. + $readme_txt = array_filter( + $potential_readme_files, + function ( $file ) { + return preg_match( '/^readme\.txt$/i', basename( $file ) ); + } + ); + + $readme = $readme_txt ? $readme_txt : $potential_readme_files; + + $matches = array(); + // Get the plugin name from readme file. + $file = self::file_preg_match( '/===(.*)===/i', $readme, $matches ); + + if ( ! $file || ! isset( $matches[1] ) ) { + return; + } + + $name = $matches[1]; + + try { + $this->validate_name_has_no_trademarks( $name ); + } catch ( Exception $e ) { + $this->add_result_error_for_file( $result, $file, $e->getMessage() ); + } + } + + /** + * Checks the readme file for default text. + * + * @since n.e.x.t + * + * @param Check_Result $result The Check Result to amend. + */ + private function check_for_name_in_main_file( Check_Result $result ) { + if ( ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $plugin_main_file = WP_PLUGIN_DIR . '/' . $result->plugin()->basename(); + $plugin_header = get_plugin_data( $plugin_main_file ); + + if ( ! empty( $plugin_header['Name'] ) ) { + try { + $this->validate_name_has_no_trademarks( $plugin_header['Name'] ); + } catch ( Exception $e ) { + $this->add_result_error_for_file( $result, $plugin_main_file, $e->getMessage() ); + } + } + } + + /** + * Checks the readme file for default text. + * + * @since n.e.x.t + * + * @param Check_Result $result The Check Result to amend. + */ + private function check_for_slug( Check_Result $result ) { + $plugin_slug = basename( $result->plugin()->path() ); + + try { + $this->validate_slug_has_no_trademarks( $plugin_slug ); + } catch ( Exception $e ) { + $this->add_result_error_for_file( $result, WP_PLUGIN_DIR . '/' . $result->plugin()->basename(), $e->getMessage() ); + } + } + + /** + * Determines if we find a trademarked term in plugin name. + * + * @since n.e.x.t + * + * @param string $plugin_name The plugin name. + * + * @throws Exception Thrown if we found trademarked term in plugin name. + */ + private function validate_name_has_no_trademarks( $plugin_name ) { + $check = $this->has_trademarked_slug( sanitize_title_with_dashes( $plugin_name ) ); + if ( ! $check ) { + return; + } + + if ( + trim( $check, '-' ) === $check + && in_array( $check, self::FOR_USE_EXCEPTIONS, true ) + ) { + // Trademarks that do NOT end in "-", but are within the FOR_USE_EXCEPTIONS array can be used, but only if it ends with 'for x'. + $message = sprintf( + /* translators: 1: plugin name, 2: found trademarked term */ + __( 'The plugin name includes a restricted term. Your chosen plugin name - %1$s - contains the restricted term "%2$s" which cannot be used within in your plugin name, unless your plugin name ends with "for %2$s". The term must still not appear anywhere else in your name.', 'plugin-check' ), + '' . esc_html( $plugin_name ) . '', + esc_html( trim( $check, '-' ) ) + ); + } elseif ( trim( $check, '-' ) === $check ) { + // Trademarks that do NOT end in "-" indicate slug cannot contain term at all. + $message = sprintf( + /* translators: 1: plugin name, 2: found trademarked term */ + __( 'The plugin name includes a restricted term. Your chosen plugin name - %1$s - contains the restricted term "%2$s" which cannot be used at all in your plugin name.', 'plugin-check' ), + '' . esc_html( $plugin_name ) . '', + esc_html( trim( $check, '-' ) ) + ); + } else { + // Trademarks ending in "-" indicate slug cannot BEGIN with that term. + $message = sprintf( + /* translators: 1: plugin name, 2: found trademarked term */ + __( 'The plugin name includes a restricted term. Your chosen plugin name - %1$s - contains the restricted term "%2$s" and cannot be used to begin your plugin name. We disallow the use of certain terms in ways that are abused, or potentially infringe on and/or are misleading with regards to trademarks. You may use the term "%2$s" elsewhere in your plugin name, such as "... for %2$s".', 'plugin-check' ), + '' . esc_html( $plugin_name ) . '', + esc_html( trim( $check, '-' ) ) + ); + } + + throw new Exception( $message ); + } + + /** + * Determines if we find a trademarked term in plugin slug. + * + * @since n.e.x.t + * + * @param string $plugin_slug The plugin slug. + * + * @throws Exception Thrown if we found trademarked term in plugin slug. + */ + private function validate_slug_has_no_trademarks( $plugin_slug ) { + $check = $this->has_trademarked_slug( $plugin_slug ); + if ( ! $check ) { + return; + } + + if ( + trim( $check, '-' ) === $check + && in_array( $check, self::FOR_USE_EXCEPTIONS, true ) + ) { + // Trademarks that do NOT end in "-", but are within the FOR_USE_EXCEPTIONS array can be used, but only if it ends with 'for x'. + $message = sprintf( + /* translators: 1: plugin slug, 2: found trademarked term */ + __( 'The plugin slug includes a restricted term. Your plugin slug - %1$s - contains the restricted term "%2$s" which cannot be used within in your plugin slug, unless your plugin slug ends with "for %2$s". The term must still not appear anywhere else in your plugin slug.', 'plugin-check' ), + '' . esc_html( $plugin_slug ) . '', + esc_html( trim( $check, '-' ) ) + ); + } elseif ( trim( $check, '-' ) === $check ) { + // Trademarks that do NOT end in "-" indicate slug cannot contain term at all. + $message = sprintf( + /* translators: 1: plugin slug, 2: found trademarked term */ + __( 'The plugin slug includes a restricted term. Your plugin slug - %1$s - contains the restricted term "%2$s" which cannot be used at all in your plugin slug.', 'plugin-check' ), + '' . esc_html( $plugin_slug ) . '', + esc_html( trim( $check, '-' ) ) + ); + } else { + // Trademarks ending in "-" indicate slug cannot BEGIN with that term. + $message = sprintf( + /* translators: 1: plugin slug, 2: found trademarked term */ + __( 'The plugin slug includes a restricted term. Your plugin slug - %1$s - contains the restricted term "%2$s" and cannot be used to begin your plugin slug. We disallow the use of certain terms in ways that are abused, or potentially infringe on and/or are misleading with regards to trademarks. You may use the term "%2$s" elsewhere in your plugin slug, such as "... for %2$s".', 'plugin-check' ), + '' . esc_html( $plugin_slug ) . '', + esc_html( trim( $check, '-' ) ) + ); + } + + throw new Exception( $message ); + } + + /** + * Whether the plugin uses a trademark in the slug. + * + * @since n.e.x.t + * + * @param string $slug The plugin slug. + * @return string|false The trademark slug if found, false otherwise. + */ + private function has_trademarked_slug( $slug ) { + // Bail early if the plugin slug not provided. + if ( empty( $slug ) ) { + return false; + } + + $has_trademarked_slug = false; + + foreach ( self::TRADEMARK_SLUGS as $trademark ) { + if ( '-' === $trademark[-1] ) { + // Trademarks ending in "-" indicate slug cannot begin with that term. + if ( 0 === strpos( $slug, $trademark ) ) { + $has_trademarked_slug = $trademark; + break; + } + } elseif ( false !== strpos( $slug, $trademark ) ) { + // Otherwise, the term cannot appear anywhere in slug. + + // check for 'for-TRADEMARK' exceptions. + if ( $this->is_valid_for_use_exception( $slug, $trademark ) ) { + // It is a valid for-use exception, try the next trademark. + continue; + } + + $has_trademarked_slug = $trademark; + break; + } + } + + // Check portmanteaus. + if ( ! $has_trademarked_slug ) { + foreach ( self::PORTMANTEAUS as $portmanteau ) { + if ( 0 === stripos( $slug, $portmanteau ) ) { + $has_trademarked_slug = $portmanteau; + break; + } + } + } + + return $has_trademarked_slug; + } + + /** + * Validates whether the trademark is valid with a for-use exception. + * + * @since n.e.x.t + * + * @param string $slug The plugin slug. + * @param string $trademark The trademark term. + * @return bool True if the trademark is valid with a for-use exception, false otherwise. + */ + private function is_valid_for_use_exception( $slug, $trademark ) { + if ( empty( $slug ) ) { + return false; + } + + if ( ! $trademark ) { + return false; + } + + if ( ! in_array( $trademark, self::FOR_USE_EXCEPTIONS, true ) ) { + return false; + } + + $for_trademark = '-for-' . $trademark; + $for_trademark_length = strlen( $for_trademark ); + if ( ! ( substr( $slug, -$for_trademark_length ) === $for_trademark ) ) { + // The slug doesn't end with 'for-TRADEMARK', so it's an invalid use. + return false; + } + + /* + * Yes if slug ENDS with 'for-TRADEMARK'. + * Validate that the term still doesn't appear in another position of the slug. + */ + $short_slug = substr( $slug, 0, -1 * strlen( $for_trademark ) ); + + // If the trademark still doesn't exist in the slug, it's OK. + return false === strpos( $short_slug, $trademark ); + } + + /** + * Amends the given result with an error for the given file, code, and message. + * + * @since n.e.x.t + * + * @param Check_Result $result The check result to amend, including the plugin context to check. + * @param string $file Absolute path to the file found. + * @param string $message Error message. + */ + private function add_result_error_for_file( Check_Result $result, $file, $message ) { + $result->add_message( + true, + $message, + array( + 'code' => 'trademarked_term', + 'file' => str_replace( $result->plugin()->path(), '', $file ), + ) + ); + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5e19d11d0..f74063093 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -17,4 +17,6 @@ parameters: # False positive as WordPress ships with a polyfill. - message: '/^Function str_contains not found.$/' - path: includes/Checker/Checks/Abstract_File_Check.php + paths: + - includes/Checker/Checks/Abstract_File_Check.php + - includes/Checker/Checks/Trademarks_Check.php diff --git a/tests/phpunit/Checker/Checks/Trademarks_Check_Tests.php b/tests/phpunit/Checker/Checks/Trademarks_Check_Tests.php new file mode 100644 index 000000000..d6babf67c --- /dev/null +++ b/tests/phpunit/Checker/Checks/Trademarks_Check_Tests.php @@ -0,0 +1,111 @@ +run( $check_result ); + + $errors = $check_result->get_errors(); + $warnings = $check_result->get_warnings(); + + $this->assertEmpty( $errors ); + $this->assertEmpty( $warnings ); + + $this->assertSame( 0, $check_result->get_error_count() ); + $this->assertSame( 0, $check_result->get_warning_count() ); + } + + /** + * @dataProvider data_trademarks_check + */ + public function test_trademarks_with_different_scenarios( $type_flag, $plugin_basename, $expected_file, $expected_error ) { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . $plugin_basename ); + $check_result = new Check_Result( $check_context ); + + $check = new Trademarks_Check( $type_flag ); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertArrayHasKey( $expected_file, $errors ); + $this->assertSame( 1, $check_result->get_error_count() ); + + $this->assertTrue( isset( $errors[ $expected_file ][0][0][0] ) ); + $this->assertSame( 'trademarked_term', $errors[ $expected_file ][0][0][0]['code'] ); + $this->assertSame( $expected_error, $errors[ $expected_file ][0][0][0]['message'] ); + } + + public function data_trademarks_check() { + return array( + 'Plugin readme - Test Plugin with readme' => array( + Trademarks_Check::TYPE_README, + 'test-trademarks-plugin-readme-errors/load.php', + 'readme.txt', + 'The plugin name includes a restricted term. Your chosen plugin name - Test Plugin with readme - contains the restricted term "plugin" which cannot be used at all in your plugin name.', + ), + 'Plugin header - Test Trademarks Plugin Header Name Errors' => array( + Trademarks_Check::TYPE_NAME, + 'test-trademarks-plugin-header-name-errors/load.php', + 'load.php', + 'The plugin name includes a restricted term. Your chosen plugin name - Test Trademarks Plugin Header Name Errors - contains the restricted term "plugin" which cannot be used at all in your plugin name.', + ), + 'Plugin slug - test-trademarks-plugin-header-slug-errors' => array( + Trademarks_Check::TYPE_SLUG, + 'test-trademarks-plugin-header-slug-errors/load.php', + 'load.php', + 'The plugin slug includes a restricted term. Your plugin slug - test-trademarks-plugin-header-slug-errors - contains the restricted term "plugin" which cannot be used at all in your plugin slug.', + ), + 'Plugin headers - WooCommerce Example String' => array( + Trademarks_Check::TYPE_NAME, + 'test-trademarks-plugin-header-woocommerce-string/load.php', + 'load.php', + 'The plugin name includes a restricted term. Your chosen plugin name - WooCommerce Example String - contains the restricted term "woocommerce" which cannot be used within in your plugin name, unless your plugin name ends with "for woocommerce". The term must still not appear anywhere else in your name.', + ), + 'Plugin headers - WooCommerce String for WooCommerce' => array( + Trademarks_Check::TYPE_NAME, + 'test-trademarks-plugin-header-woocommerce-string-for-woocommerce/load.php', + 'load.php', + 'The plugin name includes a restricted term. Your chosen plugin name - WooCommerce String for WooCommerce - contains the restricted term "woocommerce" which cannot be used within in your plugin name, unless your plugin name ends with "for woocommerce". The term must still not appear anywhere else in your name.', + ), + 'Plugin headers - WordPress String for WooCommerce' => array( + Trademarks_Check::TYPE_NAME, + 'test-trademarks-plugin-header-wordpress-string-for-woocommerce/load.php', + 'load.php', + 'The plugin name includes a restricted term. Your chosen plugin name - WordPress String for WooCommerce - contains the restricted term "wordpress" which cannot be used at all in your plugin name.', + ), + 'Plugin headers portmanteaus' => array( + Trademarks_Check::TYPE_NAME, + 'test-trademarks-plugin-header-portmanteaus/load.php', + 'load.php', + 'The plugin name includes a restricted term. Your chosen plugin name - WooXample - contains the restricted term "woo" which cannot be used at all in your plugin name.', + ), + ); + } + + public function test_trademarks_with_for_woocommerce_exceptions() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-trademarks-plugin-header-example-string-for-woocommerce/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new Trademarks_Check( Trademarks_Check::TYPE_NAME ); + $check->run( $check_result ); + + $errors = $check_result->get_errors(); + + $this->assertEmpty( $errors ); + $this->assertSame( 0, $check_result->get_error_count() ); + } +} diff --git a/tests/phpunit/testdata/plugins/test-trademarks-plugin-header-example-string-for-woocommerce/load.php b/tests/phpunit/testdata/plugins/test-trademarks-plugin-header-example-string-for-woocommerce/load.php new file mode 100644 index 000000000..a785b4d00 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-trademarks-plugin-header-example-string-for-woocommerce/load.php @@ -0,0 +1,16 @@ +