Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d5287d
Initial plan
Copilot Nov 1, 2025
b255e2a
Implement plugin directory scanning for checksums verification
Copilot Nov 1, 2025
e567bbd
Add security hardening for file operations
Copilot Nov 1, 2025
f1be4c5
Address additional code review feedback
Copilot Nov 1, 2025
9a4be37
Fix code style issues: remove empty elseif and redundant is_array check
Copilot Nov 1, 2025
97a5b26
Use get_file_data() for version detection and remove readme.txt scanning
Copilot Dec 12, 2025
2545381
Remove unnecessary file size check as get_file_data() reads only 8KB
Copilot Dec 12, 2025
51b669e
Merge branch 'main' into copilot/fix-plugin-verification-checks
swissspidy Dec 19, 2025
40f9fbc
Include renamed PHP files in version detection to fix test failure
Copilot Dec 19, 2025
890b94b
Revert to only scanning .php files and update test to pass version ex…
Copilot Dec 19, 2025
e3a4e71
Merge branch 'main' into copilot/fix-plugin-verification-checks
swissspidy Feb 3, 2026
e38a9b6
Update src/WP_CLI/Fetchers/UnfilteredPlugin.php
swissspidy Feb 3, 2026
353e4af
Merge branch 'main' into copilot/fix-plugin-verification-checks
swissspidy Feb 15, 2026
762e4ed
Apply suggestion from @swissspidy
swissspidy Mar 12, 2026
f264c89
Update src/Checksum_Plugin_Command.php
swissspidy Mar 12, 2026
d1d2931
Merge branch 'main' into copilot/fix-plugin-verification-checks
swissspidy Mar 12, 2026
bcbf764
Merge branch 'main' into copilot/fix-plugin-verification-checks
swissspidy Mar 22, 2026
062dd4b
Merge branch 'main' into copilot/fix-plugin-verification-checks
swissspidy May 19, 2026
247b4e4
Replace detect_version_from_directory with WP.org API version fallback
Copilot May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions features/checksum-plugin.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Comment thread
swissspidy marked this conversation as resolved.
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

Expand Down
82 changes: 74 additions & 8 deletions src/Checksum_Plugin_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> 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() );
}
}
Comment thread
swissspidy marked this conversation as resolved.

return $names;
}

Expand Down
22 changes: 22 additions & 0 deletions src/WP_CLI/Fetchers/UnfilteredPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ||
Expand All @@ -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;
}
}
Loading