From 97b2271dcf4504ec0b4e90534115e8c7fd9081a1 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 14 Jun 2026 13:04:22 -0600 Subject: [PATCH 1/2] Improve core library file detection --- .../Checks/Plugin_Repo/File_Type_Check.php | 428 ++++++++++++++++-- .../PHPMailer.php | 4 + .../clipboard.js | 2 + .../jquery.js | 3 +- .../assets/polyfill.js | 5 + .../includes/PHPMailer.php | 5 + .../load.php | 18 + .../src/utils/clipboard.js | 8 + .../Checker/Checks/File_Type_Check_Tests.php | 18 +- 9 files changed, 441 insertions(+), 50 deletions(-) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-errors/clipboard.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-without-errors/assets/polyfill.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-without-errors/includes/PHPMailer.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-without-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-without-errors/src/utils/clipboard.js diff --git a/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php b/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php index f470b8dd5..36b338953 100644 --- a/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php @@ -375,69 +375,401 @@ function ( $file ) use ( $plugin_path ) { * @param array $files List of absolute file paths. */ protected function look_for_library_core_files( Check_Result $result, array $files ) { - // Known libraries that are part of WordPress core. - // https://meta.trac.wordpress.org/browser/sites/trunk/api.wordpress.org/public_html/core/credits/wp-59.php#L739 . - $look_known_libraries_core_services = array( - '(?plugin()->path(); - $files = array_map( - function ( $file ) use ( $plugin_path ) { - return str_replace( $plugin_path, '', $file ); - }, - $files - ); - + $relative_files = array(); foreach ( $files as $file ) { - if ( preg_match( $combined_pattern, $file ) ) { + $relative_files[ $file ] = str_replace( $plugin_path, '', $file ); + } + + foreach ( $relative_files as $file => $relative_file ) { + foreach ( $core_libraries as $library ) { + if ( ! preg_match( $library['file_pattern'], $relative_file ) ) { + continue; + } + + if ( ! self::file_contains_core_library_signature( $file, $library ) ) { + continue; + } + $this->add_result_error_for_file( $result, __( 'Library files that are already in the WordPress core are not permitted.', 'plugin-check' ), 'library_core_files', - $file, + $relative_file, 0, 0, '', 8 ); + break; + } + } + } + + /** + * Gets WordPress core library filename candidates and required content signatures. + * + * The filename patterns intentionally match full basenames only. A matching + * filename is only a candidate; the file must also contain library-specific + * symbols, package names, global classes, or namespaced classes before the + * check reports it as a bundled copy of a WordPress core library. + * + * @since n.e.x.t + * + * @return array[] Core library file signature definitions. + */ + private static function get_core_library_file_signatures() { + return array( + array( + 'file_pattern' => '~(?:^|/)jquery(?:-[0-9.]+)?(?:\.slim)?(?:\.min)?\.js$~i', + 'content_patterns' => array( '~jQuery JavaScript Library~i', '~\bwindow\.jQuery\b~', '~\bjQuery\.fn\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery-ui(?:-[0-9.]+)?(?:\.slim)?(?:\.min)?\.js$~i', + 'content_patterns' => array( '~jQuery UI~i', '~\bjQuery\.ui\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.color(?:\.slim)?(?:\.min)?\.js$~i', + 'content_patterns' => array( '~jQuery Color~i', '~\bjQuery\.Color\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.ui\.touch-punch$~i', + 'content_patterns' => array( '~jQuery UI Touch Punch~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.hoverintent$~i', + 'content_patterns' => array( '~hoverIntent~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.imgareaselect$~i', + 'content_patterns' => array( '~imgAreaSelect~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.hotkeys$~i', + 'content_patterns' => array( '~jQuery Hotkeys~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.ba-serializeobject$~i', + 'content_patterns' => array( '~serializeObject~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.query-object$~i', + 'content_patterns' => array( '~jQuery\.query~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.suggest$~i', + 'content_patterns' => array( '~jQuery Suggest~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)polyfill(?:\.min)?\.js$~i', + 'content_patterns' => array( '~wp-polyfill~i', '~\bcore-js\b~i', '~\bregeneratorRuntime\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)iris(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bIris\b~', '~\.iris\s*\(~' ), + ), + array( + 'file_pattern' => '~(?:^|/)backbone(?:\.min)?\.js$~i', + 'content_patterns' => array( '~Backbone\.js~i', '~\bBackbone\.VERSION\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)clipboard(?:\.min)?\.js$~i', + 'content_patterns' => array( '~clipboard\.js~i', '~\bClipboardJS\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)closest(?:\.min)?\.js$~i', + 'content_patterns' => array( '~Element\.prototype\.closest~', '~element-closest~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)codemirror(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bCodeMirror\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)formdata(?:\.min)?\.js$~i', + 'content_patterns' => array( '~FormData\.prototype~', '~formdata-polyfill~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)json2(?:\.min)?\.js$~i', + 'content_patterns' => array( '~json2\.js~i', '~JSON\.stringify~' ), + ), + array( + 'file_pattern' => '~(?:^|/)lodash(?:\.min)?\.js$~i', + 'content_patterns' => array( '~lodash~i', '~\b_\.VERSION\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)masonry\.pkgd(?:\.min)?\.js$~i', + 'content_patterns' => array( '~masonry\.pkgd~i', '~\bMasonry\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)mediaelement-and-player(?:\.min)?\.js$~i', + 'content_patterns' => array( '~MediaElementPlayer~', '~\bmejs\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)moment(?:\.min)?\.js$~i', + 'content_patterns' => array( '~moment\.js~i', '~hooks\.version~', '~moment\.version~' ), + ), + array( + 'file_pattern' => '~(?:^|/)plupload\.full(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bplupload\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)thickbox(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\btb_show\b~', '~thickbox~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)twemoji(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\btwemoji\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)underscore(?:[.-]min)?\.js$~i', + 'content_patterns' => array( '~Underscore\.js~i', '~\b_\.VERSION\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)moxie(?:\.min)?\.js$~i', + 'content_patterns' => array( '~mOxie~' ), + ), + array( + 'file_pattern' => '~(?:^|/)zxcvbn(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bzxcvbn\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)getid3\.php$~i', + 'php_classes' => array( 'getID3' ), + ), + array( + 'file_pattern' => '~(?:^|/)pclzip\.lib\.php$~i', + 'php_classes' => array( 'PclZip' ), + ), + array( + 'file_pattern' => '~(?:^|/)PasswordHash\.php$~i', + 'php_classes' => array( 'PasswordHash' ), + ), + array( + 'file_pattern' => '~(?:^|/)PHPMailer\.php$~i', + 'php_classes' => array( 'PHPMailer', 'PHPMailer\PHPMailer\PHPMailer' ), + ), + array( + 'file_pattern' => '~(?:^|/)SimplePie\.php$~i', + 'php_classes' => array( 'SimplePie' ), + ), + ); + } + + /** + * Checks whether a candidate file contains a known core library signature. + * + * @since n.e.x.t + * + * @param string $file Absolute file path. + * @param array $library Core library signature definition. + * @return bool True when the file contains a matching signature, false otherwise. + */ + private static function file_contains_core_library_signature( $file, array $library ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file ); + if ( false === $contents ) { + return false; + } + + if ( isset( $library['php_classes'] ) || isset( $library['php_functions'] ) ) { + $symbols = self::get_php_symbols( $contents ); + + foreach ( $library['php_classes'] ?? array() as $class_name ) { + if ( isset( $symbols['classes'][ self::normalize_php_symbol_name( $class_name ) ] ) ) { + return true; + } + } + + foreach ( $library['php_functions'] ?? array() as $function_name ) { + if ( isset( $symbols['functions'][ self::normalize_php_symbol_name( $function_name ) ] ) ) { + return true; + } + } + } + + foreach ( $library['content_patterns'] ?? array() as $pattern ) { + if ( preg_match( $pattern, $contents ) ) { + return true; } } + + return false; + } + + /** + * Extracts global and namespaced PHP class and function symbols. + * + * @since n.e.x.t + * + * @param string $contents PHP file contents. + * @return array Extracted symbols keyed by normalized fully qualified name. + */ + private static function get_php_symbols( $contents ) { + $tokens = token_get_all( $contents ); + $namespace = ''; + $brace_depth = 0; + $pending_class_brace = false; + $class_brace_depths = array(); + $symbols = array( + 'classes' => array(), + 'functions' => array(), + ); + + foreach ( $tokens as $index => $token ) { + if ( '{' === $token ) { + ++$brace_depth; + if ( $pending_class_brace ) { + $class_brace_depths[] = $brace_depth; + $pending_class_brace = false; + } + continue; + } + + if ( '}' === $token ) { + while ( ! empty( $class_brace_depths ) && end( $class_brace_depths ) === $brace_depth ) { + array_pop( $class_brace_depths ); + } + --$brace_depth; + continue; + } + + if ( ! is_array( $token ) ) { + continue; + } + + if ( T_NAMESPACE === $token[0] ) { + $namespace = self::parse_php_namespace( $tokens, $index ); + continue; + } + + if ( in_array( $token[0], array( T_CLASS, T_INTERFACE, T_TRAIT ), true ) ) { + if ( T_CLASS === $token[0] && self::previous_php_token_is( $tokens, $index, T_DOUBLE_COLON ) ) { + continue; + } + + $class_name = self::next_php_string_token( $tokens, $index ); + if ( '' !== $class_name ) { + $symbols['classes'][ self::normalize_php_symbol_name( $namespace . '\\' . $class_name ) ] = true; + } + $pending_class_brace = true; + continue; + } + + if ( T_FUNCTION === $token[0] && empty( $class_brace_depths ) ) { + $function_name = self::next_php_string_token( $tokens, $index ); + if ( '' !== $function_name ) { + $symbols['functions'][ self::normalize_php_symbol_name( $namespace . '\\' . $function_name ) ] = true; + } + } + } + + return $symbols; + } + + /** + * Parses a namespace declaration from a PHP token stream. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $namespace_index Index of the T_NAMESPACE token. + * @return string Namespace name, or an empty string for the global namespace. + */ + private static function parse_php_namespace( array $tokens, $namespace_index ) { + $namespace = ''; + + for ( $index = $namespace_index + 1; isset( $tokens[ $index ] ); ++$index ) { + $token = $tokens[ $index ]; + if ( ';' === $token || '{' === $token ) { + break; + } + + if ( is_array( $token ) && self::is_php_name_token( $token ) ) { + $namespace .= $token[1]; + } + } + + return trim( $namespace, '\\' ); + } + + /** + * Checks whether a token can be part of a PHP qualified name. + * + * @since n.e.x.t + * + * @param array $token Token from token_get_all(). + * @return bool True when the token is a PHP name token, false otherwise. + */ + private static function is_php_name_token( array $token ) { + $name_token_ids = array( T_STRING, T_NS_SEPARATOR ); + if ( defined( 'T_NAME_QUALIFIED' ) ) { + $name_token_ids[] = constant( 'T_NAME_QUALIFIED' ); + } + if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) { + $name_token_ids[] = constant( 'T_NAME_FULLY_QUALIFIED' ); + } + + return in_array( $token[0], $name_token_ids, true ); + } + + /** + * Gets the next PHP T_STRING token after a given token index. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Token index to start after. + * @return string Token value, or an empty string when none is found. + */ + private static function next_php_string_token( array $tokens, $index ) { + for ( $next_index = $index + 1; isset( $tokens[ $next_index ] ); ++$next_index ) { + $token = $tokens[ $next_index ]; + if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { + continue; + } + + return is_array( $token ) && T_STRING === $token[0] ? $token[1] : ''; + } + + return ''; + } + + /** + * Checks whether the previous non-whitespace PHP token has the given token ID. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Token index to start before. + * @param int $token_id Token ID to check for. + * @return bool True when the previous meaningful token matches, false otherwise. + */ + private static function previous_php_token_is( array $tokens, $index, $token_id ) { + for ( $previous_index = $index - 1; $previous_index >= 0; --$previous_index ) { + $token = $tokens[ $previous_index ]; + if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { + continue; + } + + return is_array( $token ) && $token_id === $token[0]; + } + + return false; + } + + /** + * Normalizes a PHP symbol name for case-insensitive lookup. + * + * @since n.e.x.t + * + * @param string $symbol_name PHP symbol name. + * @return string Normalized PHP symbol name. + */ + private static function normalize_php_symbol_name( $symbol_name ) { + return strtolower( trim( $symbol_name, '\\' ) ); } /** diff --git a/tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-errors/PHPMailer.php b/tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-errors/PHPMailer.php index b3d9bbc7f..d3747bc6f 100644 --- a/tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-errors/PHPMailer.php +++ b/tests/phpunit/testdata/plugins/test-plugin-file-type-library-core-errors/PHPMailer.php @@ -1 +1,5 @@ get_errors(); $this->assertNotEmpty( $errors ); - $this->assertEquals( 2, $check_result->get_error_count() ); + $this->assertEquals( 3, $check_result->get_error_count() ); // Check for core PHPMailer. $this->assertArrayHasKey( 0, $errors['PHPMailer.php'] ); @@ -222,6 +222,22 @@ public function test_run_with_library_core_errors() { $this->assertArrayHasKey( 0, $errors['jquery.js'] ); $this->assertArrayHasKey( 0, $errors['jquery.js'][0] ); $this->assertCount( 1, wp_list_filter( $errors['jquery.js'][0][0], array( 'code' => 'library_core_files' ) ) ); + + // Check for core ClipboardJS. + $this->assertArrayHasKey( 0, $errors['clipboard.js'] ); + $this->assertArrayHasKey( 0, $errors['clipboard.js'][0] ); + $this->assertCount( 1, wp_list_filter( $errors['clipboard.js'][0][0], array( 'code' => 'library_core_files' ) ) ); + } + + public function test_run_with_library_core_filename_false_positives() { + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-file-type-library-core-without-errors/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check = new File_Type_Check( File_Type_Check::TYPE_LIBRARY_CORE ); + $check->run( $check_result ); + + $this->assertSame( 0, $check_result->get_error_count() ); + $this->assertEmpty( $check_result->get_errors() ); } public function test_run_with_composer_errors() { From 1ac4538834d01c3b286dc01e06595bc519bc199c Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 15 Jun 2026 13:43:29 -0600 Subject: [PATCH 2/2] Address PHPMD core library checks --- .../Core_Library_File_Signature_Checker.php | 522 ++++++++++++++++++ .../Checks/Plugin_Repo/File_Type_Check.php | 376 +------------ 2 files changed, 523 insertions(+), 375 deletions(-) create mode 100644 includes/Checker/Checks/Plugin_Repo/Core_Library_File_Signature_Checker.php diff --git a/includes/Checker/Checks/Plugin_Repo/Core_Library_File_Signature_Checker.php b/includes/Checker/Checks/Plugin_Repo/Core_Library_File_Signature_Checker.php new file mode 100644 index 000000000..54ff42276 --- /dev/null +++ b/includes/Checker/Checks/Plugin_Repo/Core_Library_File_Signature_Checker.php @@ -0,0 +1,522 @@ + '~(?:^|/)jquery(?:-[0-9.]+)?(?:\.slim)?(?:\.min)?\.js$~i', + 'content_patterns' => array( '~jQuery JavaScript Library~i', '~\bwindow\.jQuery\b~', '~\bjQuery\.fn\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery-ui(?:-[0-9.]+)?(?:\.slim)?(?:\.min)?\.js$~i', + 'content_patterns' => array( '~jQuery UI~i', '~\bjQuery\.ui\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.color(?:\.slim)?(?:\.min)?\.js$~i', + 'content_patterns' => array( '~jQuery Color~i', '~\bjQuery\.Color\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.ui\.touch-punch$~i', + 'content_patterns' => array( '~jQuery UI Touch Punch~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.hoverintent$~i', + 'content_patterns' => array( '~hoverIntent~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.imgareaselect$~i', + 'content_patterns' => array( '~imgAreaSelect~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.hotkeys$~i', + 'content_patterns' => array( '~jQuery Hotkeys~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.ba-serializeobject$~i', + 'content_patterns' => array( '~serializeObject~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.query-object$~i', + 'content_patterns' => array( '~jQuery\.query~' ), + ), + array( + 'file_pattern' => '~(?:^|/)jquery\.suggest$~i', + 'content_patterns' => array( '~jQuery Suggest~i' ), + ), + ); + } + + /** + * Gets WordPress core JavaScript library filename candidates and required content signatures. + * + * @since n.e.x.t + * + * @return array[] Core library file signature definitions. + */ + private static function get_javascript_core_library_file_signatures() { + return array( + array( + 'file_pattern' => '~(?:^|/)polyfill(?:\.min)?\.js$~i', + 'content_patterns' => array( '~wp-polyfill~i', '~\bcore-js\b~i', '~\bregeneratorRuntime\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)iris(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bIris\b~', '~\.iris\s*\(~' ), + ), + array( + 'file_pattern' => '~(?:^|/)backbone(?:\.min)?\.js$~i', + 'content_patterns' => array( '~Backbone\.js~i', '~\bBackbone\.VERSION\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)clipboard(?:\.min)?\.js$~i', + 'content_patterns' => array( '~clipboard\.js~i', '~\bClipboardJS\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)closest(?:\.min)?\.js$~i', + 'content_patterns' => array( '~Element\.prototype\.closest~', '~element-closest~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)codemirror(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bCodeMirror\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)formdata(?:\.min)?\.js$~i', + 'content_patterns' => array( '~FormData\.prototype~', '~formdata-polyfill~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)json2(?:\.min)?\.js$~i', + 'content_patterns' => array( '~json2\.js~i', '~JSON\.stringify~' ), + ), + array( + 'file_pattern' => '~(?:^|/)lodash(?:\.min)?\.js$~i', + 'content_patterns' => array( '~lodash~i', '~\b_\.VERSION\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)masonry\.pkgd(?:\.min)?\.js$~i', + 'content_patterns' => array( '~masonry\.pkgd~i', '~\bMasonry\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)mediaelement-and-player(?:\.min)?\.js$~i', + 'content_patterns' => array( '~MediaElementPlayer~', '~\bmejs\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)moment(?:\.min)?\.js$~i', + 'content_patterns' => array( '~moment\.js~i', '~hooks\.version~', '~moment\.version~' ), + ), + array( + 'file_pattern' => '~(?:^|/)plupload\.full(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bplupload\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)thickbox(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\btb_show\b~', '~thickbox~i' ), + ), + array( + 'file_pattern' => '~(?:^|/)twemoji(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\btwemoji\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)underscore(?:[.-]min)?\.js$~i', + 'content_patterns' => array( '~Underscore\.js~i', '~\b_\.VERSION\b~' ), + ), + array( + 'file_pattern' => '~(?:^|/)moxie(?:\.min)?\.js$~i', + 'content_patterns' => array( '~mOxie~' ), + ), + array( + 'file_pattern' => '~(?:^|/)zxcvbn(?:\.min)?\.js$~i', + 'content_patterns' => array( '~\bzxcvbn\b~' ), + ), + ); + } + + /** + * Gets WordPress core PHP library filename candidates and required class signatures. + * + * @since n.e.x.t + * + * @return array[] Core library file signature definitions. + */ + private static function get_php_core_library_file_signatures() { + return array( + array( + 'file_pattern' => '~(?:^|/)getid3\.php$~i', + 'php_classes' => array( 'getID3' ), + ), + array( + 'file_pattern' => '~(?:^|/)pclzip\.lib\.php$~i', + 'php_classes' => array( 'PclZip' ), + ), + array( + 'file_pattern' => '~(?:^|/)PasswordHash\.php$~i', + 'php_classes' => array( 'PasswordHash' ), + ), + array( + 'file_pattern' => '~(?:^|/)PHPMailer\.php$~i', + 'php_classes' => array( 'PHPMailer', 'PHPMailer\PHPMailer\PHPMailer' ), + ), + array( + 'file_pattern' => '~(?:^|/)SimplePie\.php$~i', + 'php_classes' => array( 'SimplePie' ), + ), + ); + } + + /** + * Checks whether a candidate file contains a known core library signature. + * + * @since n.e.x.t + * + * @param string $file Absolute file path. + * @param array $library Core library signature definition. + * @return bool True when the file contains a matching signature, false otherwise. + */ + private static function file_contains_core_library_signature( $file, array $library ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file ); + if ( false === $contents ) { + return false; + } + + if ( isset( $library['php_classes'] ) || isset( $library['php_functions'] ) ) { + $symbols = self::get_php_symbols( $contents ); + + foreach ( $library['php_classes'] ?? array() as $class_name ) { + if ( isset( $symbols['classes'][ self::normalize_php_symbol_name( $class_name ) ] ) ) { + return true; + } + } + + foreach ( $library['php_functions'] ?? array() as $function_name ) { + if ( isset( $symbols['functions'][ self::normalize_php_symbol_name( $function_name ) ] ) ) { + return true; + } + } + } + + foreach ( $library['content_patterns'] ?? array() as $pattern ) { + if ( preg_match( $pattern, $contents ) ) { + return true; + } + } + + return false; + } + + /** + * Extracts global and namespaced PHP class and function symbols. + * + * @since n.e.x.t + * + * @param string $contents PHP file contents. + * @return array Extracted symbols keyed by normalized fully qualified name. + */ + private static function get_php_symbols( $contents ) { + $tokens = token_get_all( $contents ); + $state = array( + 'namespace' => '', + 'brace_depth' => 0, + 'pending_class_brace' => false, + 'class_brace_depths' => array(), + 'symbols' => array( + 'classes' => array(), + 'functions' => array(), + ), + ); + + foreach ( $tokens as $index => $token ) { + if ( self::update_php_symbol_scope( $token, $state ) || ! is_array( $token ) ) { + continue; + } + + self::collect_php_symbol( $tokens, $index, $state ); + } + + return $state['symbols']; + } + + /** + * Updates PHP parser state for brace-delimited scopes. + * + * @since n.e.x.t + * + * @param mixed $token PHP token or token text. + * @param array $state PHP symbol parser state. + * @return bool True when the token was handled as a scope token, false otherwise. + */ + private static function update_php_symbol_scope( $token, array &$state ) { + if ( '{' === $token ) { + ++$state['brace_depth']; + if ( $state['pending_class_brace'] ) { + $state['class_brace_depths'][] = $state['brace_depth']; + $state['pending_class_brace'] = false; + } + return true; + } + + if ( '}' === $token ) { + self::leave_php_symbol_scope( $state ); + return true; + } + + return false; + } + + /** + * Leaves the current PHP symbol parser scope. + * + * @since n.e.x.t + * + * @param array $state PHP symbol parser state. + */ + private static function leave_php_symbol_scope( array &$state ) { + while ( ! empty( $state['class_brace_depths'] ) && end( $state['class_brace_depths'] ) === $state['brace_depth'] ) { + array_pop( $state['class_brace_depths'] ); + } + --$state['brace_depth']; + } + + /** + * Collects a PHP class, interface, trait, function, or namespace symbol. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Current token index. + * @param array $state PHP symbol parser state. + */ + private static function collect_php_symbol( array $tokens, $index, array &$state ) { + $token = $tokens[ $index ]; + + if ( T_NAMESPACE === $token[0] ) { + $state['namespace'] = self::parse_php_namespace( $tokens, $index ); + return; + } + + if ( self::is_php_class_declaration_token( $tokens, $index ) ) { + self::add_php_class_symbol( $tokens, $index, $state ); + return; + } + + if ( T_FUNCTION === $token[0] && empty( $state['class_brace_depths'] ) ) { + self::add_php_function_symbol( $tokens, $index, $state ); + } + } + + /** + * Checks whether the token is a class, interface, or trait declaration. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Current token index. + * @return bool True when the token declares a PHP type, false otherwise. + */ + private static function is_php_class_declaration_token( array $tokens, $index ) { + $token = $tokens[ $index ]; + if ( ! in_array( $token[0], array( T_CLASS, T_INTERFACE, T_TRAIT ), true ) ) { + return false; + } + + return T_CLASS !== $token[0] || ! self::previous_php_token_is( $tokens, $index, T_DOUBLE_COLON ); + } + + /** + * Adds a PHP class, interface, or trait symbol to parser state. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Current token index. + * @param array $state PHP symbol parser state. + */ + private static function add_php_class_symbol( array $tokens, $index, array &$state ) { + $class_name = self::next_php_string_token( $tokens, $index ); + if ( '' !== $class_name ) { + $symbol = self::normalize_php_symbol_name( $state['namespace'] . '\\' . $class_name ); + $state['symbols']['classes'][ $symbol ] = true; + } + $state['pending_class_brace'] = true; + } + + /** + * Adds a PHP function symbol to parser state. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Current token index. + * @param array $state PHP symbol parser state. + */ + private static function add_php_function_symbol( array $tokens, $index, array &$state ) { + $function_name = self::next_php_string_token( $tokens, $index ); + if ( '' === $function_name ) { + return; + } + + $symbol = self::normalize_php_symbol_name( $state['namespace'] . '\\' . $function_name ); + $state['symbols']['functions'][ $symbol ] = true; + } + + /** + * Parses a namespace declaration from a PHP token stream. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $namespace_index Index of the T_NAMESPACE token. + * @return string Namespace name, or an empty string for the global namespace. + */ + private static function parse_php_namespace( array $tokens, $namespace_index ) { + $namespace = ''; + + for ( $index = $namespace_index + 1; isset( $tokens[ $index ] ); ++$index ) { + $token = $tokens[ $index ]; + if ( ';' === $token || '{' === $token ) { + break; + } + + if ( is_array( $token ) && self::is_php_name_token( $token ) ) { + $namespace .= $token[1]; + } + } + + return trim( $namespace, '\\' ); + } + + /** + * Checks whether a token can be part of a PHP qualified name. + * + * @since n.e.x.t + * + * @param array $token Token from token_get_all(). + * @return bool True when the token is a PHP name token, false otherwise. + */ + private static function is_php_name_token( array $token ) { + $name_token_ids = array( T_STRING, T_NS_SEPARATOR ); + if ( defined( 'T_NAME_QUALIFIED' ) ) { + $name_token_ids[] = constant( 'T_NAME_QUALIFIED' ); + } + if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) { + $name_token_ids[] = constant( 'T_NAME_FULLY_QUALIFIED' ); + } + + return in_array( $token[0], $name_token_ids, true ); + } + + /** + * Gets the next PHP T_STRING token after a given token index. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Token index to start after. + * @return string Token value, or an empty string when none is found. + */ + private static function next_php_string_token( array $tokens, $index ) { + for ( $next_index = $index + 1; isset( $tokens[ $next_index ] ); ++$next_index ) { + $token = $tokens[ $next_index ]; + if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { + continue; + } + + return is_array( $token ) && T_STRING === $token[0] ? $token[1] : ''; + } + + return ''; + } + + /** + * Checks whether the previous non-whitespace PHP token has the given token ID. + * + * @since n.e.x.t + * + * @param array $tokens Token stream from token_get_all(). + * @param int $index Token index to start before. + * @param int $token_id Token ID to check for. + * @return bool True when the previous meaningful token matches, false otherwise. + */ + private static function previous_php_token_is( array $tokens, $index, $token_id ) { + for ( $previous_index = $index - 1; $previous_index >= 0; --$previous_index ) { + $token = $tokens[ $previous_index ]; + if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { + continue; + } + + return is_array( $token ) && $token_id === $token[0]; + } + + return false; + } + + /** + * Normalizes a PHP symbol name for case-insensitive lookup. + * + * @since n.e.x.t + * + * @param string $symbol_name PHP symbol name. + * @return string Normalized PHP symbol name. + */ + private static function normalize_php_symbol_name( $symbol_name ) { + return strtolower( trim( $symbol_name, '\\' ) ); + } +} diff --git a/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php b/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php index 36b338953..f9972469a 100644 --- a/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php +++ b/includes/Checker/Checks/Plugin_Repo/File_Type_Check.php @@ -375,8 +375,6 @@ function ( $file ) use ( $plugin_path ) { * @param array $files List of absolute file paths. */ protected function look_for_library_core_files( Check_Result $result, array $files ) { - $core_libraries = self::get_core_library_file_signatures(); - $plugin_path = $result->plugin()->path(); $relative_files = array(); @@ -385,15 +383,7 @@ protected function look_for_library_core_files( Check_Result $result, array $fil } foreach ( $relative_files as $file => $relative_file ) { - foreach ( $core_libraries as $library ) { - if ( ! preg_match( $library['file_pattern'], $relative_file ) ) { - continue; - } - - if ( ! self::file_contains_core_library_signature( $file, $library ) ) { - continue; - } - + if ( Core_Library_File_Signature_Checker::file_matches_core_library_signature( $file, $relative_file ) ) { $this->add_result_error_for_file( $result, __( 'Library files that are already in the WordPress core are not permitted.', 'plugin-check' ), @@ -404,372 +394,8 @@ protected function look_for_library_core_files( Check_Result $result, array $fil '', 8 ); - break; - } - } - } - - /** - * Gets WordPress core library filename candidates and required content signatures. - * - * The filename patterns intentionally match full basenames only. A matching - * filename is only a candidate; the file must also contain library-specific - * symbols, package names, global classes, or namespaced classes before the - * check reports it as a bundled copy of a WordPress core library. - * - * @since n.e.x.t - * - * @return array[] Core library file signature definitions. - */ - private static function get_core_library_file_signatures() { - return array( - array( - 'file_pattern' => '~(?:^|/)jquery(?:-[0-9.]+)?(?:\.slim)?(?:\.min)?\.js$~i', - 'content_patterns' => array( '~jQuery JavaScript Library~i', '~\bwindow\.jQuery\b~', '~\bjQuery\.fn\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery-ui(?:-[0-9.]+)?(?:\.slim)?(?:\.min)?\.js$~i', - 'content_patterns' => array( '~jQuery UI~i', '~\bjQuery\.ui\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.color(?:\.slim)?(?:\.min)?\.js$~i', - 'content_patterns' => array( '~jQuery Color~i', '~\bjQuery\.Color\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.ui\.touch-punch$~i', - 'content_patterns' => array( '~jQuery UI Touch Punch~i' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.hoverintent$~i', - 'content_patterns' => array( '~hoverIntent~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.imgareaselect$~i', - 'content_patterns' => array( '~imgAreaSelect~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.hotkeys$~i', - 'content_patterns' => array( '~jQuery Hotkeys~i' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.ba-serializeobject$~i', - 'content_patterns' => array( '~serializeObject~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.query-object$~i', - 'content_patterns' => array( '~jQuery\.query~' ), - ), - array( - 'file_pattern' => '~(?:^|/)jquery\.suggest$~i', - 'content_patterns' => array( '~jQuery Suggest~i' ), - ), - array( - 'file_pattern' => '~(?:^|/)polyfill(?:\.min)?\.js$~i', - 'content_patterns' => array( '~wp-polyfill~i', '~\bcore-js\b~i', '~\bregeneratorRuntime\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)iris(?:\.min)?\.js$~i', - 'content_patterns' => array( '~\bIris\b~', '~\.iris\s*\(~' ), - ), - array( - 'file_pattern' => '~(?:^|/)backbone(?:\.min)?\.js$~i', - 'content_patterns' => array( '~Backbone\.js~i', '~\bBackbone\.VERSION\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)clipboard(?:\.min)?\.js$~i', - 'content_patterns' => array( '~clipboard\.js~i', '~\bClipboardJS\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)closest(?:\.min)?\.js$~i', - 'content_patterns' => array( '~Element\.prototype\.closest~', '~element-closest~i' ), - ), - array( - 'file_pattern' => '~(?:^|/)codemirror(?:\.min)?\.js$~i', - 'content_patterns' => array( '~\bCodeMirror\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)formdata(?:\.min)?\.js$~i', - 'content_patterns' => array( '~FormData\.prototype~', '~formdata-polyfill~i' ), - ), - array( - 'file_pattern' => '~(?:^|/)json2(?:\.min)?\.js$~i', - 'content_patterns' => array( '~json2\.js~i', '~JSON\.stringify~' ), - ), - array( - 'file_pattern' => '~(?:^|/)lodash(?:\.min)?\.js$~i', - 'content_patterns' => array( '~lodash~i', '~\b_\.VERSION\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)masonry\.pkgd(?:\.min)?\.js$~i', - 'content_patterns' => array( '~masonry\.pkgd~i', '~\bMasonry\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)mediaelement-and-player(?:\.min)?\.js$~i', - 'content_patterns' => array( '~MediaElementPlayer~', '~\bmejs\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)moment(?:\.min)?\.js$~i', - 'content_patterns' => array( '~moment\.js~i', '~hooks\.version~', '~moment\.version~' ), - ), - array( - 'file_pattern' => '~(?:^|/)plupload\.full(?:\.min)?\.js$~i', - 'content_patterns' => array( '~\bplupload\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)thickbox(?:\.min)?\.js$~i', - 'content_patterns' => array( '~\btb_show\b~', '~thickbox~i' ), - ), - array( - 'file_pattern' => '~(?:^|/)twemoji(?:\.min)?\.js$~i', - 'content_patterns' => array( '~\btwemoji\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)underscore(?:[.-]min)?\.js$~i', - 'content_patterns' => array( '~Underscore\.js~i', '~\b_\.VERSION\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)moxie(?:\.min)?\.js$~i', - 'content_patterns' => array( '~mOxie~' ), - ), - array( - 'file_pattern' => '~(?:^|/)zxcvbn(?:\.min)?\.js$~i', - 'content_patterns' => array( '~\bzxcvbn\b~' ), - ), - array( - 'file_pattern' => '~(?:^|/)getid3\.php$~i', - 'php_classes' => array( 'getID3' ), - ), - array( - 'file_pattern' => '~(?:^|/)pclzip\.lib\.php$~i', - 'php_classes' => array( 'PclZip' ), - ), - array( - 'file_pattern' => '~(?:^|/)PasswordHash\.php$~i', - 'php_classes' => array( 'PasswordHash' ), - ), - array( - 'file_pattern' => '~(?:^|/)PHPMailer\.php$~i', - 'php_classes' => array( 'PHPMailer', 'PHPMailer\PHPMailer\PHPMailer' ), - ), - array( - 'file_pattern' => '~(?:^|/)SimplePie\.php$~i', - 'php_classes' => array( 'SimplePie' ), - ), - ); - } - - /** - * Checks whether a candidate file contains a known core library signature. - * - * @since n.e.x.t - * - * @param string $file Absolute file path. - * @param array $library Core library signature definition. - * @return bool True when the file contains a matching signature, false otherwise. - */ - private static function file_contains_core_library_signature( $file, array $library ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $contents = file_get_contents( $file ); - if ( false === $contents ) { - return false; - } - - if ( isset( $library['php_classes'] ) || isset( $library['php_functions'] ) ) { - $symbols = self::get_php_symbols( $contents ); - - foreach ( $library['php_classes'] ?? array() as $class_name ) { - if ( isset( $symbols['classes'][ self::normalize_php_symbol_name( $class_name ) ] ) ) { - return true; - } - } - - foreach ( $library['php_functions'] ?? array() as $function_name ) { - if ( isset( $symbols['functions'][ self::normalize_php_symbol_name( $function_name ) ] ) ) { - return true; - } - } - } - - foreach ( $library['content_patterns'] ?? array() as $pattern ) { - if ( preg_match( $pattern, $contents ) ) { - return true; - } - } - - return false; - } - - /** - * Extracts global and namespaced PHP class and function symbols. - * - * @since n.e.x.t - * - * @param string $contents PHP file contents. - * @return array Extracted symbols keyed by normalized fully qualified name. - */ - private static function get_php_symbols( $contents ) { - $tokens = token_get_all( $contents ); - $namespace = ''; - $brace_depth = 0; - $pending_class_brace = false; - $class_brace_depths = array(); - $symbols = array( - 'classes' => array(), - 'functions' => array(), - ); - - foreach ( $tokens as $index => $token ) { - if ( '{' === $token ) { - ++$brace_depth; - if ( $pending_class_brace ) { - $class_brace_depths[] = $brace_depth; - $pending_class_brace = false; - } - continue; - } - - if ( '}' === $token ) { - while ( ! empty( $class_brace_depths ) && end( $class_brace_depths ) === $brace_depth ) { - array_pop( $class_brace_depths ); - } - --$brace_depth; - continue; - } - - if ( ! is_array( $token ) ) { - continue; - } - - if ( T_NAMESPACE === $token[0] ) { - $namespace = self::parse_php_namespace( $tokens, $index ); - continue; - } - - if ( in_array( $token[0], array( T_CLASS, T_INTERFACE, T_TRAIT ), true ) ) { - if ( T_CLASS === $token[0] && self::previous_php_token_is( $tokens, $index, T_DOUBLE_COLON ) ) { - continue; - } - - $class_name = self::next_php_string_token( $tokens, $index ); - if ( '' !== $class_name ) { - $symbols['classes'][ self::normalize_php_symbol_name( $namespace . '\\' . $class_name ) ] = true; - } - $pending_class_brace = true; - continue; - } - - if ( T_FUNCTION === $token[0] && empty( $class_brace_depths ) ) { - $function_name = self::next_php_string_token( $tokens, $index ); - if ( '' !== $function_name ) { - $symbols['functions'][ self::normalize_php_symbol_name( $namespace . '\\' . $function_name ) ] = true; - } - } - } - - return $symbols; - } - - /** - * Parses a namespace declaration from a PHP token stream. - * - * @since n.e.x.t - * - * @param array $tokens Token stream from token_get_all(). - * @param int $namespace_index Index of the T_NAMESPACE token. - * @return string Namespace name, or an empty string for the global namespace. - */ - private static function parse_php_namespace( array $tokens, $namespace_index ) { - $namespace = ''; - - for ( $index = $namespace_index + 1; isset( $tokens[ $index ] ); ++$index ) { - $token = $tokens[ $index ]; - if ( ';' === $token || '{' === $token ) { - break; - } - - if ( is_array( $token ) && self::is_php_name_token( $token ) ) { - $namespace .= $token[1]; - } - } - - return trim( $namespace, '\\' ); - } - - /** - * Checks whether a token can be part of a PHP qualified name. - * - * @since n.e.x.t - * - * @param array $token Token from token_get_all(). - * @return bool True when the token is a PHP name token, false otherwise. - */ - private static function is_php_name_token( array $token ) { - $name_token_ids = array( T_STRING, T_NS_SEPARATOR ); - if ( defined( 'T_NAME_QUALIFIED' ) ) { - $name_token_ids[] = constant( 'T_NAME_QUALIFIED' ); - } - if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) { - $name_token_ids[] = constant( 'T_NAME_FULLY_QUALIFIED' ); - } - - return in_array( $token[0], $name_token_ids, true ); - } - - /** - * Gets the next PHP T_STRING token after a given token index. - * - * @since n.e.x.t - * - * @param array $tokens Token stream from token_get_all(). - * @param int $index Token index to start after. - * @return string Token value, or an empty string when none is found. - */ - private static function next_php_string_token( array $tokens, $index ) { - for ( $next_index = $index + 1; isset( $tokens[ $next_index ] ); ++$next_index ) { - $token = $tokens[ $next_index ]; - if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { - continue; } - - return is_array( $token ) && T_STRING === $token[0] ? $token[1] : ''; } - - return ''; - } - - /** - * Checks whether the previous non-whitespace PHP token has the given token ID. - * - * @since n.e.x.t - * - * @param array $tokens Token stream from token_get_all(). - * @param int $index Token index to start before. - * @param int $token_id Token ID to check for. - * @return bool True when the previous meaningful token matches, false otherwise. - */ - private static function previous_php_token_is( array $tokens, $index, $token_id ) { - for ( $previous_index = $index - 1; $previous_index >= 0; --$previous_index ) { - $token = $tokens[ $previous_index ]; - if ( is_array( $token ) && T_WHITESPACE === $token[0] ) { - continue; - } - - return is_array( $token ) && $token_id === $token[0]; - } - - return false; - } - - /** - * Normalizes a PHP symbol name for case-insensitive lookup. - * - * @since n.e.x.t - * - * @param string $symbol_name PHP symbol name. - * @return string Normalized PHP symbol name. - */ - private static function normalize_php_symbol_name( $symbol_name ) { - return strtolower( trim( $symbol_name, '\\' ) ); } /**