Skip to content
Open
81 changes: 73 additions & 8 deletions features/checksum-plugin.feature
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,7 @@ Feature: Validate checksums for WordPress plugins
"""

When I try `wp plugin verify-checksums --all --exclude=akismet`
Then STDOUT should contain:
"""
Verified 0 of 1 plugins (1 skipped).
"""
Then STDOUT should match /^Success: Verified \d of \d plugins \(\d skipped\)\./

Scenario: Plugin is verified when the --exclude argument isn't included
Given a WP install
Expand All @@ -189,10 +186,7 @@ Feature: Validate checksums for WordPress plugins
"""

When I try `wp plugin verify-checksums --all`
Then STDOUT should contain:
"""
Verified 1 of 1 plugins.
"""
Then STDOUT should match /^Success: Verified \d of \d plugins/

# Hello Dolly was moved from a single file to a directory in WordPress 6.9
@less-than-wp-6.9
Expand All @@ -204,3 +198,74 @@ Feature: Validate checksums for WordPress plugins
"""
Verified 1 of 1 plugins.
"""

Scenario: Verify must-use plugin that is a standard plugin moved to mu-plugins
Given a WP install

When I run `wp plugin delete --all`

And I run `wp plugin install duplicate-post --version=3.2.1`
Then STDOUT should not be empty

When I run `mv wp-content/plugins/duplicate-post wp-content/mu-plugins/`
Then STDERR should be empty

When I try `wp plugin verify-checksums --all`
Then STDOUT should match /Success: Verified \d of \d plugins/
And STDERR should not contain:
"""
duplicate-post
"""

Scenario: Exclude must-use plugins from verification
Given a WP install

When I run `wp plugin install duplicate-post --version=3.2.1`
Then STDOUT should not be empty

When I run `mv wp-content/plugins/duplicate-post wp-content/mu-plugins/`
Then STDERR should be empty

When I run `wp plugin delete --all`

And I run `wp plugin verify-checksums --all --exclude-mu-plugins`
Then STDOUT should contain:
"""
Plugin already verified.
"""

Scenario: Modified must-use plugin doesn't verify
Given a WP install

When I run `wp plugin install duplicate-post --version=3.2.1`
Then STDOUT should not be empty

When I run `mv wp-content/plugins/duplicate-post wp-content/mu-plugins/`
Then STDERR should be empty

Given "Duplicate Post" replaced with "Different Name" in the wp-content/mu-plugins/duplicate-post/duplicate-post.php file

When I try `wp plugin verify-checksums --all --format=json`
Then STDOUT should contain:
"""
"plugin_name":"duplicate-post","file":"duplicate-post.php","message":"Checksum does not match"
"""
And STDERR should match /Error: Only verified \d of \d plugins/

Scenario: Single-file must-use plugin without checksums shows warning
Given a WP install
And a wp-content/mu-plugins/custom-mu-plugin.php file:
"""
<?php
/**
* Plugin Name: Custom MU Plugin
* Version: 1.0.0
*/
"""

When I run `wp plugin verify-checksums --all 2>&1`
Then STDOUT should contain:
"""
Warning: Must-use plugin 'custom-mu-plugin.php' appears to be a custom file or loader plugin and cannot be verified.
"""
And STDOUT should match /Success: Verified \d of \d plugins \(\d skipped\)\./
182 changes: 170 additions & 12 deletions src/Checksum_Plugin_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ class Checksum_Plugin_Command extends Checksum_Base_Command {
* [--exclude=<name>]
* : Comma separated list of plugin names that should be excluded from verifying.
*
* [--exclude-mu-plugins]
* : Exclude must-use plugins from verification.
*
* ## EXAMPLES
*
* # Verify the checksums of all installed plugins
Expand All @@ -74,11 +77,13 @@ 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 );
$plugins = $fetcher->get_many( $all ? $this->get_all_plugin_names() : $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 ) ) ) : [];

/**
* @var string $exclude
Expand Down Expand Up @@ -149,6 +154,27 @@ public function __invoke( $args, $assoc_args ) {
}
}

$total = count( $plugins );

foreach ( $mu_plugins as $mu_file => $mu_plugin ) {
$plugin_name = $this->get_plugin_slug_from_path( $mu_file );

if ( ! empty( $args ) ) {
if ( ! in_array( $plugin_name, $args, true ) ) {
continue;
} else {
++$total;
}
}

if ( in_array( $plugin_name, $exclude_list, true ) ) {
++$skips;
continue;
}

$this->verify_mu_plugin( $mu_file, $mu_plugin, $plugin_name, $version_arg, $insecure, $strict, $skips );
}

if ( ! empty( $this->errors ) ) {
$formatter = new Formatter(
$assoc_args,
Expand All @@ -157,7 +183,10 @@ public function __invoke( $args, $assoc_args ) {
$formatter->display_items( $this->errors );
}

$total = count( $plugins );
if ( $all ) {
$total += count( $mu_plugins );
}

$failures = count( array_unique( array_column( $this->errors, 'plugin_name' ) ) );
$successes = $total - $failures - $skips;

Expand Down Expand Up @@ -268,22 +297,23 @@ private function get_plugin_files( $path ) {
* @param string $path Relative path to the plugin file to check the
* integrity of.
* @param array $checksums Array of provided checksums to compare against.
* @param string $base_dir Optional. Base directory for the plugin. Defaults to WP_PLUGIN_DIR.
*
* @return bool|string
*/
private function check_file_checksum( $path, $checksums ) {
private function check_file_checksum( $path, $checksums, $base_dir = null ) {
if ( $this->supports_sha256()
&& array_key_exists( 'sha256', $checksums )
) {
$sha256 = $this->get_sha256( $this->get_absolute_path( $path ) );
$sha256 = $this->get_sha256( $this->get_absolute_path( $path, $base_dir ) );
return in_array( $sha256, (array) $checksums['sha256'], true );
}

if ( ! array_key_exists( 'md5', $checksums ) ) {
return 'No matching checksum algorithm found';
}

$md5 = $this->get_md5( $this->get_absolute_path( $path ) );
$md5 = $this->get_md5( $this->get_absolute_path( $path, $base_dir ) );

return in_array( $md5, (array) $checksums['md5'], true );
}
Expand Down Expand Up @@ -327,12 +357,16 @@ private function get_md5( $filepath ) {
/**
* Gets the absolute path to a relative plugin file.
*
* @param string $path Relative path to get the absolute path for.
* @param string $path Relative path to get the absolute path for.
* @param string $base_dir Optional. Base directory to prepend. Defaults to WP_PLUGIN_DIR.
*
* @return string
*/
private function get_absolute_path( $path ) {
return WP_PLUGIN_DIR . '/' . $path;
private function get_absolute_path( $path, $base_dir = null ) {
if ( null === $base_dir ) {
$base_dir = WP_PLUGIN_DIR;
}
return $base_dir . '/' . $path;
}

/**
Expand Down Expand Up @@ -361,4 +395,128 @@ private function get_soft_change_files() {
private function is_soft_change_file( $file ) {
return in_array( strtolower( $file ), $this->get_soft_change_files(), true );
}

/**
* Extracts the plugin slug from the plugin file path.
*
* For MU plugins that are actually standard plugins moved to mu-plugins folder,
* we extract the plugin slug from the file path to look up checksums.
*
* @param string $plugin_file Path to the plugin file.
*
* @return string Plugin slug.
*/
private function get_plugin_slug_from_path( $plugin_file ) {
// If it's in a subdirectory, use the directory name as slug.
if ( false !== strpos( $plugin_file, '/' ) ) {
return dirname( $plugin_file );
}

// For single files, extract the slug from the filename.
return basename( $plugin_file, '.php' );
}

/**
* Gets the version for a plugin from its header data or the version argument.
*
* @param string $version_arg Version argument from command line.
* @param array $plugin_data Plugin header data.
*
* @return string|false Plugin version, or false if not found.
*/
private function get_plugin_version_for_verification( $version_arg, $plugin_data ) {
if ( ! empty( $version_arg ) ) {
return $version_arg;
}

if ( ! empty( $plugin_data['Version'] ) ) {
return $plugin_data['Version'];
}

return false;
}

/**
* Verifies a must-use plugin against WordPress.org checksums.
*
* @param string $mu_file Path to the MU plugin file.
* @param array $mu_plugin Plugin header data.
* @param string $plugin_name Plugin slug/name.
* @param string $version_arg Version to verify against (if specified).
* @param bool $insecure Whether to allow insecure connections.
* @param bool $strict Whether to check soft change files.
* @param int &$skips Reference to skip counter.
*/
private function verify_mu_plugin( $mu_file, $mu_plugin, $plugin_name, $version_arg, $insecure, $strict, &$skips ) {
$is_single_file = false === strpos( $mu_file, '/' );

// Get version from the plugin header.
$version = $this->get_plugin_version_for_verification( $version_arg, $mu_plugin );

if ( false === $version ) {
WP_CLI::warning( "Could not retrieve the version for must-use plugin {$plugin_name}, skipping." );
++$skips;
return;
}

$wp_org_api = new WpOrgApi( [ 'insecure' => $insecure ] );

try {
/**
* @var array|false $checksums
*/
$checksums = $wp_org_api->get_plugin_checksums( $plugin_name, $version );
if ( false === $checksums ) {
throw new Exception( "Could not retrieve the checksums for version {$version} of must-use plugin {$plugin_name}, skipping." );
}
} catch ( Exception $exception ) {
// If it's a single file or we can't get checksums, warn the user.
if ( $is_single_file ) {
WP_CLI::warning( "Must-use plugin '{$mu_file}' appears to be a custom file or loader plugin and cannot be verified." );
} else {
WP_CLI::warning( $exception->getMessage() );
}
++$skips;
return;
}

$files = $this->get_mu_plugin_files( $mu_file );

foreach ( $files as $file ) {
if ( ! array_key_exists( $file, $checksums ) ) {
$this->add_error( $plugin_name, $file, 'File was added' );
continue;
}

if ( ! $strict && $this->is_soft_change_file( $file ) ) {
continue;
}

// Build the relative path for MU plugins.
$relative_path = $is_single_file ? $file : dirname( $mu_file ) . '/' . $file;

$result = $this->check_file_checksum( $relative_path, $checksums[ $file ], WPMU_PLUGIN_DIR );
if ( true !== $result ) {
$this->add_error( $plugin_name, $file, is_string( $result ) ? $result : 'Checksum does not match' );
}
}
}

/**
* Gets the list of files that are part of the given MU plugin.
*
* @param string $mu_file Path to the main MU plugin file.
*
* @return array<string> Array of files with their relative paths.
*/
private function get_mu_plugin_files( $mu_file ) {
// If it's a single file in the root of mu-plugins, return just that file.
if ( false === strpos( $mu_file, '/' ) ) {
return array( $mu_file );
}

// If it's in a subdirectory, get all files in that directory.
$folder = WPMU_PLUGIN_DIR . '/' . dirname( $mu_file );
return $this->get_files( trailingslashit( $folder ) );
}
}