Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
34 changes: 34 additions & 0 deletions features/checksum-plugin.feature
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,37 @@ 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 on lines +232 to +240
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test scenario for verifying a plugin directory with --all flag (lines 232-240) doesn't specify whether version detection succeeds or fails. If the version cannot be auto-detected from the renamed file, the plugin would be skipped with a "Could not retrieve the version" warning, not verified. The test should either verify that version detection succeeds from other PHP files in the directory, or it should expect the version warning and skip message. Consider adding explicit assertions about the version detection outcome.

Copilot uses AI. Check for mistakes.
73 changes: 71 additions & 2 deletions src/Checksum_Plugin_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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 @@ -222,24 +228,87 @@ private function get_plugin_version( $path ) {
}

if ( ! array_key_exists( $path, $this->plugins_data ) ) {
return false;
// Try to detect version from any PHP file in the plugin directory
return $this->detect_version_from_directory( dirname( $path ) );
}

return $this->plugins_data[ $path ]['Version'];
}

/**
* Attempts to detect plugin version from any PHP file in the plugin directory.
*
* This is used as a fallback when the main plugin file is missing or has no valid headers.
*
* @param string $plugin_dir Plugin directory name (relative to WP_PLUGIN_DIR).
*
* @return string|false Detected version, or false if not found.
*/
private function detect_version_from_directory( $plugin_dir ) {
$plugin_path = WP_PLUGIN_DIR . '/' . $plugin_dir;

// If it's not a directory (single-file plugin), we can't detect version
if ( ! is_dir( $plugin_path ) ) {
return false;
}

// Try scanning PHP files for Version header using WordPress's get_file_data()
$files = glob( $plugin_path . '/*.php' );
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The glob pattern does not escape special glob characters in the plugin directory path. If a plugin directory contains characters like *, ?, [, or ], the glob function could match unintended files or fail unexpectedly. Consider using glob() with the GLOB_BRACE flag or pre-escaping the path with a function that escapes glob metacharacters, or use an alternative approach like scandir() followed by filtering.

Copilot uses AI. Check for mistakes.
if ( is_array( $files ) && ! empty( $files ) ) {
foreach ( $files as $file ) {
if ( is_readable( $file ) ) {
$file_data = get_file_data(
$file,
array( 'Version' => 'Version' )
);
if ( ! empty( $file_data['Version'] ) ) {
return $file_data['Version'];
}
}
}
}
// If glob() failed (returns false), version will just not be detected from PHP files

return false;
}

/**
* 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 );
}

return $names;
// Also scan the filesystem for plugin directories
$plugin_dir = WP_PLUGIN_DIR;
if ( is_dir( $plugin_dir ) && is_readable( $plugin_dir ) ) {
$dirs = @scandir( $plugin_dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( false !== $dirs ) {
foreach ( $dirs as $dir ) {
// Skip special directories and files
if ( '.' === $dir || '..' === $dir ) {
continue;
}

$full_path = $plugin_dir . '/' . $dir;
// Only include real directories, not symlinks or files
if ( is_dir( $full_path ) && ! is_link( $full_path ) && ! in_array( $dir, $names, true ) ) {
$names[] = $dir;
}
}
}
}
Comment on lines +293 to +309

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider refactoring this block to use DirectoryIterator. This provides a more modern, object-oriented approach to iterating over filesystem entries and handles errors through exceptions, which is cleaner than suppressing warnings with @scandir. It also simplifies the loop by providing methods like isDot(), isDir(), and isLink(), making the code more readable and robust.

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 array_unique( $names );

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to array_unique() is redundant here. The ! in_array( $dir, $names, true ) check within the loop already ensures that no duplicate plugin names are added to the $names array. Removing this call will make the code slightly more efficient and clearer.

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 @@ -25,6 +25,7 @@ class UnfilteredPlugin extends Base {
* @return object|false
*/
public function get( $name ) {
// First, check plugins detected by get_plugins()
foreach ( get_plugins() as $file => $_ ) {
if ( "{$name}.php" === $file ||
( $name && $file === $name ) ||
Expand All @@ -33,6 +34,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