Skip to content

Commit d1d2931

Browse files
committed
Merge branch 'main' into copilot/fix-plugin-verification-checks
2 parents f264c89 + f0ab610 commit d1d2931

5 files changed

Lines changed: 251 additions & 26 deletions

File tree

.github/workflows/copilot-setup-steps.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ jobs:
2121

2222
- name: Check existence of composer.json file
2323
id: check_composer_file
24-
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3
25-
with:
26-
files: "composer.json"
24+
run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT"
2725

2826
- name: Set up PHP environment
2927
if: steps.check_composer_file.outputs.files_exists == 'true'
@@ -38,7 +36,7 @@ jobs:
3836

3937
- name: Install Composer dependencies & cache dependencies
4038
if: steps.check_composer_file.outputs.files_exists == 'true'
41-
uses: ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520 # v3
39+
uses: ramsey/composer-install@a35c6ebd3d08125aaf8852dff361e686a1a67947 # v3
4240
env:
4341
COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }}
4442
with:

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ site.
9696
Verifies plugin files against WordPress.org's checksums.
9797

9898
~~~
99-
wp plugin verify-checksums [<plugin>...] [--all] [--strict] [--version=<version>] [--format=<format>] [--insecure] [--exclude=<name>]
99+
wp plugin verify-checksums [<plugin>...] [--all] [--strict] [--version=<version>] [--format=<format>] [--insecure] [--exclude=<name>] [--exclude-mu-plugins]
100100
~~~
101101

102102
**OPTIONS**
@@ -132,6 +132,9 @@ wp plugin verify-checksums [<plugin>...] [--all] [--strict] [--version=<version>
132132
[--exclude=<name>]
133133
Comma separated list of plugin names that should be excluded from verifying.
134134

135+
[--exclude-mu-plugins]
136+
Exclude must-use plugins from verification.
137+
135138
**EXAMPLES**
136139

137140
# Verify the checksums of all installed plugins

features/checksum-plugin.feature

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,7 @@ Feature: Validate checksums for WordPress plugins
167167
"""
168168

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

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

191188
When I try `wp plugin verify-checksums --all`
192-
Then STDOUT should contain:
193-
"""
194-
Verified 1 of 1 plugins.
195-
"""
189+
Then STDOUT should match /^Success: Verified \d of \d plugins/
196190

197191
# Hello Dolly was moved from a single file to a directory in WordPress 6.9
198192
@less-than-wp-6.9
@@ -238,3 +232,74 @@ Feature: Validate checksums for WordPress plugins
238232
"""
239233
Warning: Plugin duplicate-post main file is missing: duplicate-post/duplicate-post.php
240234
"""
235+
236+
Scenario: Verify must-use plugin that is a standard plugin moved to mu-plugins
237+
Given a WP install
238+
239+
When I run `wp plugin delete --all`
240+
241+
And I run `wp plugin install duplicate-post --version=3.2.1`
242+
Then STDOUT should not be empty
243+
244+
When I run `mv wp-content/plugins/duplicate-post wp-content/mu-plugins/`
245+
Then STDERR should be empty
246+
247+
When I try `wp plugin verify-checksums --all`
248+
Then STDOUT should match /Success: Verified \d of \d plugins/
249+
And STDERR should not contain:
250+
"""
251+
duplicate-post
252+
"""
253+
254+
Scenario: Exclude must-use plugins from verification
255+
Given a WP install
256+
257+
When I run `wp plugin install duplicate-post --version=3.2.1`
258+
Then STDOUT should not be empty
259+
260+
When I run `mv wp-content/plugins/duplicate-post wp-content/mu-plugins/`
261+
Then STDERR should be empty
262+
263+
When I run `wp plugin delete --all`
264+
265+
And I run `wp plugin verify-checksums --all --exclude-mu-plugins`
266+
Then STDOUT should contain:
267+
"""
268+
Plugin already verified.
269+
"""
270+
271+
Scenario: Modified must-use plugin doesn't verify
272+
Given a WP install
273+
274+
When I run `wp plugin install duplicate-post --version=3.2.1`
275+
Then STDOUT should not be empty
276+
277+
When I run `mv wp-content/plugins/duplicate-post wp-content/mu-plugins/`
278+
Then STDERR should be empty
279+
280+
Given "Duplicate Post" replaced with "Different Name" in the wp-content/mu-plugins/duplicate-post/duplicate-post.php file
281+
282+
When I try `wp plugin verify-checksums --all --format=json`
283+
Then STDOUT should contain:
284+
"""
285+
"plugin_name":"duplicate-post","file":"duplicate-post.php","message":"Checksum does not match"
286+
"""
287+
And STDERR should match /Error: Only verified \d of \d plugins/
288+
289+
Scenario: Single-file must-use plugin without checksums shows warning
290+
Given a WP install
291+
And a wp-content/mu-plugins/custom-mu-plugin.php file:
292+
"""
293+
<?php
294+
/**
295+
* Plugin Name: Custom MU Plugin
296+
* Version: 1.0.0
297+
*/
298+
"""
299+
300+
When I run `wp plugin verify-checksums --all 2>&1`
301+
Then STDOUT should contain:
302+
"""
303+
Warning: Must-use plugin 'custom-mu-plugin.php' appears to be a custom file or loader plugin and cannot be verified.
304+
"""
305+
And STDOUT should match /Success: Verified \d of \d plugins \(\d skipped\)\./

src/Checksum_Plugin_Command.php

Lines changed: 170 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ class Checksum_Plugin_Command extends Checksum_Base_Command {
6262
* [--exclude=<name>]
6363
* : Comma separated list of plugin names that should be excluded from verifying.
6464
*
65+
* [--exclude-mu-plugins]
66+
* : Exclude must-use plugins from verification.
67+
*
6568
* ## EXAMPLES
6669
*
6770
* # Verify the checksums of all installed plugins
@@ -74,11 +77,13 @@ class Checksum_Plugin_Command extends Checksum_Base_Command {
7477
*/
7578
public function __invoke( $args, $assoc_args ) {
7679

77-
$fetcher = new Fetchers\UnfilteredPlugin();
78-
$all = Utils\get_flag_value( $assoc_args, 'all', false );
79-
$strict = Utils\get_flag_value( $assoc_args, 'strict', false );
80-
$insecure = Utils\get_flag_value( $assoc_args, 'insecure', false );
81-
$plugins = $fetcher->get_many( $all ? $this->get_all_plugin_names() : $args );
80+
$fetcher = new Fetchers\UnfilteredPlugin();
81+
$all = Utils\get_flag_value( $assoc_args, 'all', false );
82+
$strict = Utils\get_flag_value( $assoc_args, 'strict', false );
83+
$insecure = Utils\get_flag_value( $assoc_args, 'insecure', false );
84+
$exclude_mu = Utils\get_flag_value( $assoc_args, 'exclude-mu-plugins', false );
85+
$plugins = $fetcher->get_many( $all ? $this->get_all_plugin_names() : $args );
86+
$mu_plugins = ! $exclude_mu ? array_merge( get_mu_plugins(), get_plugins( '/../' . basename( WPMU_PLUGIN_DIR ) ) ) : [];
8287

8388
/**
8489
* @var string $exclude
@@ -155,6 +160,27 @@ public function __invoke( $args, $assoc_args ) {
155160
}
156161
}
157162

163+
$total = count( $plugins );
164+
165+
foreach ( $mu_plugins as $mu_file => $mu_plugin ) {
166+
$plugin_name = $this->get_plugin_slug_from_path( $mu_file );
167+
168+
if ( ! empty( $args ) ) {
169+
if ( ! in_array( $plugin_name, $args, true ) ) {
170+
continue;
171+
} else {
172+
++$total;
173+
}
174+
}
175+
176+
if ( in_array( $plugin_name, $exclude_list, true ) ) {
177+
++$skips;
178+
continue;
179+
}
180+
181+
$this->verify_mu_plugin( $mu_file, $mu_plugin, $plugin_name, $version_arg, $insecure, $strict, $skips );
182+
}
183+
158184
if ( ! empty( $this->errors ) ) {
159185
$formatter = new Formatter(
160186
$assoc_args,
@@ -163,7 +189,10 @@ public function __invoke( $args, $assoc_args ) {
163189
$formatter->display_items( $this->errors );
164190
}
165191

166-
$total = count( $plugins );
192+
if ( $all ) {
193+
$total += count( $mu_plugins );
194+
}
195+
167196
$failures = count( array_unique( array_column( $this->errors, 'plugin_name' ) ) );
168197
$successes = $total - $failures - $skips;
169198

@@ -335,22 +364,23 @@ private function get_plugin_files( $path ) {
335364
* @param string $path Relative path to the plugin file to check the
336365
* integrity of.
337366
* @param array $checksums Array of provided checksums to compare against.
367+
* @param string $base_dir Optional. Base directory for the plugin. Defaults to WP_PLUGIN_DIR.
338368
*
339369
* @return bool|string
340370
*/
341-
private function check_file_checksum( $path, $checksums ) {
371+
private function check_file_checksum( $path, $checksums, $base_dir = null ) {
342372
if ( $this->supports_sha256()
343373
&& array_key_exists( 'sha256', $checksums )
344374
) {
345-
$sha256 = $this->get_sha256( $this->get_absolute_path( $path ) );
375+
$sha256 = $this->get_sha256( $this->get_absolute_path( $path, $base_dir ) );
346376
return in_array( $sha256, (array) $checksums['sha256'], true );
347377
}
348378

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

353-
$md5 = $this->get_md5( $this->get_absolute_path( $path ) );
383+
$md5 = $this->get_md5( $this->get_absolute_path( $path, $base_dir ) );
354384

355385
return in_array( $md5, (array) $checksums['md5'], true );
356386
}
@@ -394,12 +424,16 @@ private function get_md5( $filepath ) {
394424
/**
395425
* Gets the absolute path to a relative plugin file.
396426
*
397-
* @param string $path Relative path to get the absolute path for.
427+
* @param string $path Relative path to get the absolute path for.
428+
* @param string $base_dir Optional. Base directory to prepend. Defaults to WP_PLUGIN_DIR.
398429
*
399430
* @return string
400431
*/
401-
private function get_absolute_path( $path ) {
402-
return WP_PLUGIN_DIR . '/' . $path;
432+
private function get_absolute_path( $path, $base_dir = null ) {
433+
if ( null === $base_dir ) {
434+
$base_dir = WP_PLUGIN_DIR;
435+
}
436+
return $base_dir . '/' . $path;
403437
}
404438

405439
/**
@@ -428,4 +462,128 @@ private function get_soft_change_files() {
428462
private function is_soft_change_file( $file ) {
429463
return in_array( strtolower( $file ), $this->get_soft_change_files(), true );
430464
}
465+
466+
/**
467+
* Extracts the plugin slug from the plugin file path.
468+
*
469+
* For MU plugins that are actually standard plugins moved to mu-plugins folder,
470+
* we extract the plugin slug from the file path to look up checksums.
471+
*
472+
* @param string $plugin_file Path to the plugin file.
473+
*
474+
* @return string Plugin slug.
475+
*/
476+
private function get_plugin_slug_from_path( $plugin_file ) {
477+
// If it's in a subdirectory, use the directory name as slug.
478+
if ( false !== strpos( $plugin_file, '/' ) ) {
479+
return dirname( $plugin_file );
480+
}
481+
482+
// For single files, extract the slug from the filename.
483+
return basename( $plugin_file, '.php' );
484+
}
485+
486+
/**
487+
* Gets the version for a plugin from its header data or the version argument.
488+
*
489+
* @param string $version_arg Version argument from command line.
490+
* @param array $plugin_data Plugin header data.
491+
*
492+
* @return string|false Plugin version, or false if not found.
493+
*/
494+
private function get_plugin_version_for_verification( $version_arg, $plugin_data ) {
495+
if ( ! empty( $version_arg ) ) {
496+
return $version_arg;
497+
}
498+
499+
if ( ! empty( $plugin_data['Version'] ) ) {
500+
return $plugin_data['Version'];
501+
}
502+
503+
return false;
504+
}
505+
506+
/**
507+
* Verifies a must-use plugin against WordPress.org checksums.
508+
*
509+
* @param string $mu_file Path to the MU plugin file.
510+
* @param array $mu_plugin Plugin header data.
511+
* @param string $plugin_name Plugin slug/name.
512+
* @param string $version_arg Version to verify against (if specified).
513+
* @param bool $insecure Whether to allow insecure connections.
514+
* @param bool $strict Whether to check soft change files.
515+
* @param int &$skips Reference to skip counter.
516+
*/
517+
private function verify_mu_plugin( $mu_file, $mu_plugin, $plugin_name, $version_arg, $insecure, $strict, &$skips ) {
518+
$is_single_file = false === strpos( $mu_file, '/' );
519+
520+
// Get version from the plugin header.
521+
$version = $this->get_plugin_version_for_verification( $version_arg, $mu_plugin );
522+
523+
if ( false === $version ) {
524+
WP_CLI::warning( "Could not retrieve the version for must-use plugin {$plugin_name}, skipping." );
525+
++$skips;
526+
return;
527+
}
528+
529+
$wp_org_api = new WpOrgApi( [ 'insecure' => $insecure ] );
530+
531+
try {
532+
/**
533+
* @var array|false $checksums
534+
*/
535+
$checksums = $wp_org_api->get_plugin_checksums( $plugin_name, $version );
536+
if ( false === $checksums ) {
537+
throw new Exception( "Could not retrieve the checksums for version {$version} of must-use plugin {$plugin_name}, skipping." );
538+
}
539+
} catch ( Exception $exception ) {
540+
// If it's a single file or we can't get checksums, warn the user.
541+
if ( $is_single_file ) {
542+
WP_CLI::warning( "Must-use plugin '{$mu_file}' appears to be a custom file or loader plugin and cannot be verified." );
543+
} else {
544+
WP_CLI::warning( $exception->getMessage() );
545+
}
546+
++$skips;
547+
return;
548+
}
549+
550+
$files = $this->get_mu_plugin_files( $mu_file );
551+
552+
foreach ( $files as $file ) {
553+
if ( ! array_key_exists( $file, $checksums ) ) {
554+
$this->add_error( $plugin_name, $file, 'File was added' );
555+
continue;
556+
}
557+
558+
if ( ! $strict && $this->is_soft_change_file( $file ) ) {
559+
continue;
560+
}
561+
562+
// Build the relative path for MU plugins.
563+
$relative_path = $is_single_file ? $file : dirname( $mu_file ) . '/' . $file;
564+
565+
$result = $this->check_file_checksum( $relative_path, $checksums[ $file ], WPMU_PLUGIN_DIR );
566+
if ( true !== $result ) {
567+
$this->add_error( $plugin_name, $file, is_string( $result ) ? $result : 'Checksum does not match' );
568+
}
569+
}
570+
}
571+
572+
/**
573+
* Gets the list of files that are part of the given MU plugin.
574+
*
575+
* @param string $mu_file Path to the main MU plugin file.
576+
*
577+
* @return array<string> Array of files with their relative paths.
578+
*/
579+
private function get_mu_plugin_files( $mu_file ) {
580+
// If it's a single file in the root of mu-plugins, return just that file.
581+
if ( false === strpos( $mu_file, '/' ) ) {
582+
return array( $mu_file );
583+
}
584+
585+
// If it's in a subdirectory, get all files in that directory.
586+
$folder = WPMU_PLUGIN_DIR . '/' . dirname( $mu_file );
587+
return $this->get_files( trailingslashit( $folder ) );
588+
}
431589
}

src/WP_CLI/Fetchers/UnfilteredPlugin.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class UnfilteredPlugin extends Base {
2727
*/
2828
public function get( $name ) {
2929
$name = (string) $name;
30-
// First, check plugins detected by get_plugins()
30+
31+
// First, check plugins detected by get_plugins()
3132
foreach ( get_plugins() as $file => $_ ) {
3233
if ( "{$name}.php" === $file ||
3334
( $name && $file === $name ) ||

0 commit comments

Comments
 (0)