diff --git a/features/checksum-plugin.feature b/features/checksum-plugin.feature index 41131c35..98920c6f 100644 --- a/features/checksum-plugin.feature +++ b/features/checksum-plugin.feature @@ -183,6 +183,44 @@ Feature: Validate checksums for WordPress plugins Verified 1 of 1 plugins. """ + Scenario: Verifies plugin directory when main file is missing + Given a WP install + + When I run `wp plugin install duplicate-post --version=3.2.1` + Then STDOUT should not be empty + And STDERR should be empty + + When I run `mv wp-content/plugins/duplicate-post/duplicate-post.php wp-content/plugins/duplicate-post/duplicate-post.php.renamed` + Then STDERR should be empty + + When I try `wp plugin verify-checksums duplicate-post --version=3.2.1 --format=json` + Then STDOUT should contain: + """ + "plugin_name":"duplicate-post","file":"duplicate-post.php.renamed","message":"File was added" + """ + And STDERR should contain: + """ + Warning: Plugin duplicate-post main file is missing: duplicate-post/duplicate-post.php + """ + And STDERR should contain: + """ + Error: No plugins verified (1 failed). + """ + + When I try `wp plugin verify-checksums --all --format=json` + Then STDOUT should contain: + """ + "plugin_name":"duplicate-post" + """ + And STDERR should contain: + """ + Warning: Plugin duplicate-post main file is missing: duplicate-post/duplicate-post.php + """ + And STDERR should contain: + """ + Warning: Could not determine the version of plugin duplicate-post from its files. + """ + Scenario: Verify must-use plugin that is a standard plugin moved to mu-plugins Given a WP install diff --git a/src/Checksum_Plugin_Command.php b/src/Checksum_Plugin_Command.php index 32a2f119..cf3f38f2 100644 --- a/src/Checksum_Plugin_Command.php +++ b/src/Checksum_Plugin_Command.php @@ -26,6 +26,13 @@ class Checksum_Plugin_Command extends Checksum_Base_Command { */ private $errors = array(); + /** + * Whether to skip TLS certificate verification for remote requests. + * + * @var bool + */ + private $insecure = false; + /** * Verifies plugin files against WordPress.org's checksums. * @@ -77,13 +84,14 @@ class Checksum_Plugin_Command extends Checksum_Base_Command { */ public function __invoke( $args, $assoc_args ) { - $fetcher = new Fetchers\UnfilteredPlugin(); - $all = Utils\get_flag_value( $assoc_args, 'all', false ); - $strict = Utils\get_flag_value( $assoc_args, 'strict', false ); - $insecure = Utils\get_flag_value( $assoc_args, 'insecure', false ); - $exclude_mu = Utils\get_flag_value( $assoc_args, 'exclude-mu-plugins', false ); - $plugins = $fetcher->get_many( $all ? $this->get_all_plugin_names() : $args ); - $mu_plugins = ! $exclude_mu ? array_merge( get_mu_plugins(), get_plugins( '/../' . basename( WPMU_PLUGIN_DIR ) ) ) : []; + $fetcher = new Fetchers\UnfilteredPlugin(); + $all = Utils\get_flag_value( $assoc_args, 'all', false ); + $strict = Utils\get_flag_value( $assoc_args, 'strict', false ); + $this->insecure = Utils\get_flag_value( $assoc_args, 'insecure', false ); + $insecure = $this->insecure; + $exclude_mu = Utils\get_flag_value( $assoc_args, 'exclude-mu-plugins', false ); + $plugins = $fetcher->get_many( $all ? $this->get_all_plugin_names() : $args ); + $mu_plugins = ! $exclude_mu ? array_merge( get_mu_plugins(), get_plugins( '/../' . basename( WPMU_PLUGIN_DIR ) ) ) : []; /** * @var string $exclude @@ -112,6 +120,12 @@ public function __invoke( $args, $assoc_args ) { continue; } + // Check if the main plugin file exists + $main_file_path = WP_PLUGIN_DIR . '/' . $plugin->file; + if ( ! file_exists( $main_file_path ) ) { + WP_CLI::warning( "Plugin {$plugin->name} main file is missing: {$plugin->file}" ); + } + if ( false === $version ) { WP_CLI::warning( "Could not retrieve the version for plugin {$plugin->name}, skipping." ); ++$skips; @@ -251,23 +265,75 @@ private function get_plugin_version( $path ) { } if ( ! array_key_exists( $path, $this->plugins_data ) ) { - return false; + return $this->fetch_version_from_wp_org( dirname( $path ) ); } return $this->plugins_data[ $path ]['Version']; } + /** + * Fetches the current stable plugin version from WordPress.org as a fallback. + * + * Used when the plugin version cannot be determined locally (e.g. main file missing). + * Logs a warning to indicate that the version is being assumed. + * + * @param string $plugin_slug Plugin slug. + * + * @return string|false Stable version from WordPress.org, or false on failure. + */ + private function fetch_version_from_wp_org( $plugin_slug ) { + $wp_org_api = new WpOrgApi( [ 'insecure' => $this->insecure ] ); + + try { + $plugin_data = $wp_org_api->get_plugin_info( $plugin_slug ); + } catch ( Exception $exception ) { + return false; + } + + if ( ! is_array( $plugin_data ) || empty( $plugin_data['version'] ) ) { + return false; + } + + $version = $plugin_data['version']; + WP_CLI::warning( "Could not determine the version of plugin {$plugin_slug} from its files. Assuming the current stable version ({$version}) from WordPress.org." ); + + return $version; + } + /** * Gets the names of all installed plugins. * + * Includes both plugins detected by get_plugins() and plugin directories + * that exist on the filesystem but may not have valid headers. + * * @return array Names of all installed plugins. */ private function get_all_plugin_names() { $names = array(); + + // Get plugins from get_plugins() (those with valid headers) foreach ( get_plugins() as $file => $details ) { $names[] = Utils\get_plugin_name( $file ); } + // Also scan the filesystem for plugin directories + $plugin_dir = WP_PLUGIN_DIR; + if ( is_dir( $plugin_dir ) && is_readable( $plugin_dir ) ) { + try { + foreach ( new DirectoryIterator( $plugin_dir ) as $fileinfo ) { + if ( $fileinfo->isDot() || ! $fileinfo->isDir() || $fileinfo->isLink() ) { + continue; + } + $dir = $fileinfo->getFilename(); + if ( ! in_array( $dir, $names, true ) ) { + $names[] = $dir; + } + } + } catch ( UnexpectedValueException $e ) { + WP_CLI::warning( "Could not scan plugin directory '{$plugin_dir}': " . $e->getMessage() ); + } + } + return $names; } diff --git a/src/WP_CLI/Fetchers/UnfilteredPlugin.php b/src/WP_CLI/Fetchers/UnfilteredPlugin.php index 4076178b..152cbf35 100644 --- a/src/WP_CLI/Fetchers/UnfilteredPlugin.php +++ b/src/WP_CLI/Fetchers/UnfilteredPlugin.php @@ -28,6 +28,7 @@ class UnfilteredPlugin extends Base { public function get( $name ) { $name = (string) $name; + // First, check plugins detected by get_plugins() foreach ( get_plugins() as $file => $_ ) { if ( "{$name}.php" === $file || ( $name && $file === $name ) || @@ -36,6 +37,27 @@ public function get( $name ) { } } + // If not found, check if a directory with this name exists + // This handles cases where the main plugin file is missing + $plugin_dir = WP_PLUGIN_DIR . '/' . $name; + + // Resolve real paths to protect against path traversal and symlinks. + $wp_plugin_dir_real = realpath( WP_PLUGIN_DIR ); + $plugin_dir_real = realpath( $plugin_dir ); + + if ( false !== $wp_plugin_dir_real + && false !== $plugin_dir_real + && is_dir( $plugin_dir_real ) + && ! is_link( $plugin_dir_real ) + && ( $plugin_dir_real === $wp_plugin_dir_real + || 0 === strpos( $plugin_dir_real, $wp_plugin_dir_real . DIRECTORY_SEPARATOR ) ) + ) { + // Use the conventional main file name, even if it doesn't exist + // The checksum verification will handle missing files appropriately + $file = $name . '/' . $name . '.php'; + return (object) compact( 'name', 'file' ); + } + return false; } }