From 58cd3da6da4cb433fba8405d374f579dea0660a0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 6 Nov 2025 09:33:05 +0000
Subject: [PATCH 1/8] Initial plan
From e6c6ab6ecb2e7965e5d8abd973af2c2ed4dd0dcc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 6 Nov 2025 09:49:01 +0000
Subject: [PATCH 2/8] Add cache validation for plugin and theme downloads
- Create PackageValidator class to validate downloaded zip files
- Implement validation checks: file size >= 20 bytes and zip integrity test with unzip
- Create UpgraderWithValidation trait for WP_Upgrader classes
- Add ValidatingPluginUpgrader and ValidatingThemeUpgrader classes
- Update DestructivePluginUpgrader and DestructiveThemeUpgrader to use validation
- Integrate validation into Plugin_Command and Theme_Command
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/Plugin_Command.php | 2 +-
src/Theme_Command.php | 2 +-
src/WP_CLI/DestructivePluginUpgrader.php | 1 +
src/WP_CLI/DestructiveThemeUpgrader.php | 1 +
src/WP_CLI/PackageValidator.php | 127 +++++++++++++++++++++++
src/WP_CLI/UpgraderWithValidation.php | 127 +++++++++++++++++++++++
src/WP_CLI/ValidatingPluginUpgrader.php | 13 +++
src/WP_CLI/ValidatingThemeUpgrader.php | 13 +++
8 files changed, 284 insertions(+), 2 deletions(-)
create mode 100644 src/WP_CLI/PackageValidator.php
create mode 100644 src/WP_CLI/UpgraderWithValidation.php
create mode 100644 src/WP_CLI/ValidatingPluginUpgrader.php
create mode 100644 src/WP_CLI/ValidatingThemeUpgrader.php
diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php
index ef5b061fb..2606aa1cc 100644
--- a/src/Plugin_Command.php
+++ b/src/Plugin_Command.php
@@ -79,7 +79,7 @@ public function __construct() {
}
protected function get_upgrader_class( $force ) {
- return $force ? '\\WP_CLI\\DestructivePluginUpgrader' : 'Plugin_Upgrader';
+ return $force ? '\\WP_CLI\\DestructivePluginUpgrader' : '\\WP_CLI\\ValidatingPluginUpgrader';
}
/**
diff --git a/src/Theme_Command.php b/src/Theme_Command.php
index 5ae8459f0..5b17f9fb1 100644
--- a/src/Theme_Command.php
+++ b/src/Theme_Command.php
@@ -75,7 +75,7 @@ public function __construct() {
}
protected function get_upgrader_class( $force ) {
- return $force ? '\\WP_CLI\\DestructiveThemeUpgrader' : 'Theme_Upgrader';
+ return $force ? '\\WP_CLI\\DestructiveThemeUpgrader' : '\\WP_CLI\\ValidatingThemeUpgrader';
}
/**
diff --git a/src/WP_CLI/DestructivePluginUpgrader.php b/src/WP_CLI/DestructivePluginUpgrader.php
index 5b3e17746..b1df4a395 100644
--- a/src/WP_CLI/DestructivePluginUpgrader.php
+++ b/src/WP_CLI/DestructivePluginUpgrader.php
@@ -6,6 +6,7 @@
* A plugin upgrader class that clears the destination directory.
*/
class DestructivePluginUpgrader extends \Plugin_Upgrader {
+ use UpgraderWithValidation;
public function install_package( $args = array() ) {
parent::upgrade_strings(); // Needed for the 'remove_old' string.
diff --git a/src/WP_CLI/DestructiveThemeUpgrader.php b/src/WP_CLI/DestructiveThemeUpgrader.php
index 699d6dc0e..9f2e318d9 100644
--- a/src/WP_CLI/DestructiveThemeUpgrader.php
+++ b/src/WP_CLI/DestructiveThemeUpgrader.php
@@ -6,6 +6,7 @@
* A theme upgrader class that clears the destination directory.
*/
class DestructiveThemeUpgrader extends \Theme_Upgrader {
+ use UpgraderWithValidation;
public function install_package( $args = array() ) {
parent::upgrade_strings(); // Needed for the 'remove_old' string.
diff --git a/src/WP_CLI/PackageValidator.php b/src/WP_CLI/PackageValidator.php
new file mode 100644
index 000000000..a81c65f70
--- /dev/null
+++ b/src/WP_CLI/PackageValidator.php
@@ -0,0 +1,127 @@
+return_code );
+ }
+
+ return $is_available;
+ }
+
+ /**
+ * Validates zip file integrity using the 'unzip -t' command.
+ *
+ * @param string $file_path Path to the zip file.
+ * @return true|\WP_Error True if valid, WP_Error if validation fails.
+ */
+ private static function validate_with_unzip( $file_path ) {
+ $result = WP_CLI::launch(
+ sprintf( 'unzip -t %s', escapeshellarg( $file_path ) ),
+ false,
+ true
+ );
+
+ if ( 0 !== $result->return_code ) {
+ return new \WP_Error(
+ 'package_corrupted',
+ sprintf(
+ 'Package file failed zip integrity check. This usually indicates a corrupted or incomplete download.'
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Deletes a corrupted package file.
+ *
+ * @param string $file_path Path to the file to delete.
+ * @return bool True if file was deleted or didn't exist, false on failure.
+ */
+ public static function delete_corrupted_file( $file_path ) {
+ if ( ! file_exists( $file_path ) ) {
+ return true;
+ }
+
+ return @unlink( $file_path );
+ }
+}
diff --git a/src/WP_CLI/UpgraderWithValidation.php b/src/WP_CLI/UpgraderWithValidation.php
new file mode 100644
index 000000000..ca54f0ff9
--- /dev/null
+++ b/src/WP_CLI/UpgraderWithValidation.php
@@ -0,0 +1,127 @@
+get_error_message()
+ ),
+ 'extension-command'
+ );
+
+ // Delete the corrupted file.
+ PackageValidator::delete_corrupted_file( $download );
+ WP_CLI::debug(
+ 'Deleted corrupted package file, attempting fresh download...',
+ 'extension-command'
+ );
+
+ // Try to download again by clearing any cache.
+ // We need to bypass the cache, which we can do by using a modified package URL.
+ // However, WP_Upgrader doesn't provide a direct way to do this.
+ // Instead, we'll use a filter to modify the download behavior.
+ $retry_download = $this->download_package_retry( $package, $check_signatures, $hook_extra );
+
+ // If retry succeeded, validate it again.
+ if ( ! is_wp_error( $retry_download ) ) {
+ $retry_validation = PackageValidator::validate( $retry_download );
+
+ if ( true === $retry_validation ) {
+ WP_CLI::debug( 'Fresh download succeeded and validated.', 'extension-command' );
+ return $retry_download;
+ }
+
+ // Even the retry is corrupted - delete it and give up.
+ PackageValidator::delete_corrupted_file( $retry_download );
+ WP_CLI::debug( 'Retry download also failed validation.', 'extension-command' );
+ }
+
+ // Both attempts failed - return an error.
+ return new \WP_Error(
+ 'package_validation_failed',
+ 'Downloaded package failed validation. The file may be corrupted or the download URL may be returning an error instead of a valid zip file. Please check your network connection and try again.'
+ );
+ }
+
+ /**
+ * Retries downloading a package, bypassing cache.
+ *
+ * This is called when the initial download (which may have come from cache)
+ * failed validation.
+ *
+ * @param string $package The URI of the package.
+ * @param bool $check_signatures Whether to validate file signatures.
+ * @param array $hook_extra Extra arguments to pass to hooked filters.
+ * @return string|\WP_Error The full path to the downloaded package file, or a WP_Error object.
+ */
+ private function download_package_retry( $package, $check_signatures, $hook_extra ) {
+ // Add a filter to disable caching for this download.
+ $disable_cache = function( $args, $url ) {
+ // Disable caching by setting a short timeout and unique filename.
+ $args['reject_cache'] = true;
+ return $args;
+ };
+
+ // WP HTTP API doesn't have reject_cache, so we'll use a different approach.
+ // We'll hook into pre_http_request to force a fresh download.
+ $force_fresh_download = function( $preempt, $args, $url ) use ( $package ) {
+ // Only apply to our specific package URL.
+ if ( $url !== $package ) {
+ return $preempt;
+ }
+ // Return false to proceed with the request (not using preempt).
+ // The cache is managed by WP-CLI's cache manager, not WP's HTTP API.
+ return false;
+ };
+
+ add_filter( 'pre_http_request', $force_fresh_download, 10, 3 );
+
+ // Attempt the download again.
+ $result = parent::download_package( $package, $check_signatures, $hook_extra );
+
+ remove_filter( 'pre_http_request', $force_fresh_download, 10 );
+
+ return $result;
+ }
+}
diff --git a/src/WP_CLI/ValidatingPluginUpgrader.php b/src/WP_CLI/ValidatingPluginUpgrader.php
new file mode 100644
index 000000000..dd52c2486
--- /dev/null
+++ b/src/WP_CLI/ValidatingPluginUpgrader.php
@@ -0,0 +1,13 @@
+
Date: Thu, 6 Nov 2025 09:50:18 +0000
Subject: [PATCH 3/8] Simplify cache validation retry logic
Instead of attempting automatic retry, delete corrupted file and ask user to retry.
This is simpler and more reliable since WP-CLI cache manager handles re-downloads.
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/WP_CLI/UpgraderWithValidation.php | 83 +++++----------------------
1 file changed, 13 insertions(+), 70 deletions(-)
diff --git a/src/WP_CLI/UpgraderWithValidation.php b/src/WP_CLI/UpgraderWithValidation.php
index ca54f0ff9..65cb95782 100644
--- a/src/WP_CLI/UpgraderWithValidation.php
+++ b/src/WP_CLI/UpgraderWithValidation.php
@@ -17,7 +17,7 @@ trait UpgraderWithValidation {
*
* This method overrides WP_Upgrader::download_package() to add validation
* of the downloaded file before it's used for installation. If validation
- * fails, the file is deleted and re-downloaded.
+ * fails, the corrupted file is deleted and an error is returned.
*
* @param string $package The URI of the package.
* @param bool $check_signatures Whether to validate file signatures. Default false.
@@ -41,7 +41,7 @@ public function download_package( $package, $check_signatures = false, $hook_ext
return $download;
}
- // Validation failed - log the issue and attempt recovery.
+ // Validation failed - log the issue and clean up.
WP_CLI::debug(
sprintf(
'Package validation failed: %s',
@@ -50,78 +50,21 @@ public function download_package( $package, $check_signatures = false, $hook_ext
'extension-command'
);
- // Delete the corrupted file.
- PackageValidator::delete_corrupted_file( $download );
- WP_CLI::debug(
- 'Deleted corrupted package file, attempting fresh download...',
- 'extension-command'
- );
-
- // Try to download again by clearing any cache.
- // We need to bypass the cache, which we can do by using a modified package URL.
- // However, WP_Upgrader doesn't provide a direct way to do this.
- // Instead, we'll use a filter to modify the download behavior.
- $retry_download = $this->download_package_retry( $package, $check_signatures, $hook_extra );
-
- // If retry succeeded, validate it again.
- if ( ! is_wp_error( $retry_download ) ) {
- $retry_validation = PackageValidator::validate( $retry_download );
-
- if ( true === $retry_validation ) {
- WP_CLI::debug( 'Fresh download succeeded and validated.', 'extension-command' );
- return $retry_download;
- }
-
- // Even the retry is corrupted - delete it and give up.
- PackageValidator::delete_corrupted_file( $retry_download );
- WP_CLI::debug( 'Retry download also failed validation.', 'extension-command' );
+ // Delete the corrupted file to prevent it from being reused.
+ if ( PackageValidator::delete_corrupted_file( $download ) ) {
+ WP_CLI::debug(
+ 'Deleted corrupted package file from cache.',
+ 'extension-command'
+ );
}
- // Both attempts failed - return an error.
+ // Return a detailed error message.
return new \WP_Error(
'package_validation_failed',
- 'Downloaded package failed validation. The file may be corrupted or the download URL may be returning an error instead of a valid zip file. Please check your network connection and try again.'
+ sprintf(
+ 'Downloaded package failed validation (%s). The corrupted file has been removed from cache. Please try the command again.',
+ $validation->get_error_message()
+ )
);
}
-
- /**
- * Retries downloading a package, bypassing cache.
- *
- * This is called when the initial download (which may have come from cache)
- * failed validation.
- *
- * @param string $package The URI of the package.
- * @param bool $check_signatures Whether to validate file signatures.
- * @param array $hook_extra Extra arguments to pass to hooked filters.
- * @return string|\WP_Error The full path to the downloaded package file, or a WP_Error object.
- */
- private function download_package_retry( $package, $check_signatures, $hook_extra ) {
- // Add a filter to disable caching for this download.
- $disable_cache = function( $args, $url ) {
- // Disable caching by setting a short timeout and unique filename.
- $args['reject_cache'] = true;
- return $args;
- };
-
- // WP HTTP API doesn't have reject_cache, so we'll use a different approach.
- // We'll hook into pre_http_request to force a fresh download.
- $force_fresh_download = function( $preempt, $args, $url ) use ( $package ) {
- // Only apply to our specific package URL.
- if ( $url !== $package ) {
- return $preempt;
- }
- // Return false to proceed with the request (not using preempt).
- // The cache is managed by WP-CLI's cache manager, not WP's HTTP API.
- return false;
- };
-
- add_filter( 'pre_http_request', $force_fresh_download, 10, 3 );
-
- // Attempt the download again.
- $result = parent::download_package( $package, $check_signatures, $hook_extra );
-
- remove_filter( 'pre_http_request', $force_fresh_download, 10 );
-
- return $result;
- }
}
From c3bd78859bf39b087a87b7b391ae78d025303cd6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 6 Nov 2025 09:52:11 +0000
Subject: [PATCH 4/8] Improve PackageValidator for cross-platform compatibility
- Suppress unzip command output to avoid cluttering console
- Use platform-appropriate null device (NUL on Windows, /dev/null on Unix)
- Add proper output redirection for both version check and validation
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/WP_CLI/PackageValidator.php | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/src/WP_CLI/PackageValidator.php b/src/WP_CLI/PackageValidator.php
index a81c65f70..ff671ac19 100644
--- a/src/WP_CLI/PackageValidator.php
+++ b/src/WP_CLI/PackageValidator.php
@@ -75,8 +75,10 @@ private static function is_unzip_available() {
if ( null === $is_available ) {
// Check if unzip is in PATH by trying to get its version.
- $result = WP_CLI::launch(
- 'unzip -v',
+ // Suppress output to avoid cluttering the console.
+ $null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
+ $result = WP_CLI::launch(
+ sprintf( 'unzip -v > %s 2>&1', $null_device ),
false,
true
);
@@ -93,8 +95,16 @@ private static function is_unzip_available() {
* @return true|\WP_Error True if valid, WP_Error if validation fails.
*/
private static function validate_with_unzip( $file_path ) {
+ // Suppress output - use platform-appropriate null device.
+ $null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
+ $command = sprintf(
+ 'unzip -t %s > %s 2>&1',
+ escapeshellarg( $file_path ),
+ $null_device
+ );
+
$result = WP_CLI::launch(
- sprintf( 'unzip -t %s', escapeshellarg( $file_path ) ),
+ $command,
false,
true
);
@@ -102,9 +112,7 @@ private static function validate_with_unzip( $file_path ) {
if ( 0 !== $result->return_code ) {
return new \WP_Error(
'package_corrupted',
- sprintf(
- 'Package file failed zip integrity check. This usually indicates a corrupted or incomplete download.'
- )
+ 'Package file failed zip integrity check. This usually indicates a corrupted or incomplete download.'
);
}
From 61d08c9e02cb2e45e9f349feace61a43b6adba12 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 6 Nov 2025 09:54:00 +0000
Subject: [PATCH 5/8] Update phpcs configuration for new validator classes
Add exclusions for PackageValidator, ValidatingPluginUpgrader, ValidatingThemeUpgrader,
and UpgraderWithValidation from namespace prefix rules
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
phpcs.xml.dist | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index d0cb7801d..6611eeb87 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -59,7 +59,7 @@
*/src/WP_CLI/Fetchers/(Plugin|Theme)\.php$
*/src/WP_CLI/CommandWithUpgrade\.php$
- */src/WP_CLI/(CommandWith|DestructivePlugin|DestructiveTheme)Upgrader\.php$
+ */src/WP_CLI/(CommandWith|DestructivePlugin|DestructiveTheme|ValidatingPlugin|ValidatingTheme|PackageValidator|UpgraderWithValidation).*\.php$
*/src/WP_CLI/Parse(Plugin|Theme)NameInput\.php$
From e8a6f23e0b6d18ce68697d6fdc66b7430a055ba5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 6 Nov 2025 09:57:43 +0000
Subject: [PATCH 6/8] Address code review feedback - improve security and code
quality
- Properly escape null device path in shell commands to prevent injection
- Remove error suppression operator and add explicit error logging
- Use more precise phpcs exclude patterns instead of wildcards
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
phpcs.xml.dist | 7 ++++++-
src/WP_CLI/PackageValidator.php | 20 +++++++++++++-------
2 files changed, 19 insertions(+), 8 deletions(-)
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 6611eeb87..f723979bd 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -59,7 +59,12 @@
*/src/WP_CLI/Fetchers/(Plugin|Theme)\.php$
*/src/WP_CLI/CommandWithUpgrade\.php$
- */src/WP_CLI/(CommandWith|DestructivePlugin|DestructiveTheme|ValidatingPlugin|ValidatingTheme|PackageValidator|UpgraderWithValidation).*\.php$
+ */src/WP_CLI/DestructivePluginUpgrader\.php$
+ */src/WP_CLI/DestructiveThemeUpgrader\.php$
+ */src/WP_CLI/ValidatingPluginUpgrader\.php$
+ */src/WP_CLI/ValidatingThemeUpgrader\.php$
+ */src/WP_CLI/PackageValidator\.php$
+ */src/WP_CLI/UpgraderWithValidation\.php$
*/src/WP_CLI/Parse(Plugin|Theme)NameInput\.php$
diff --git a/src/WP_CLI/PackageValidator.php b/src/WP_CLI/PackageValidator.php
index ff671ac19..bde838b29 100644
--- a/src/WP_CLI/PackageValidator.php
+++ b/src/WP_CLI/PackageValidator.php
@@ -78,7 +78,7 @@ private static function is_unzip_available() {
// Suppress output to avoid cluttering the console.
$null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
$result = WP_CLI::launch(
- sprintf( 'unzip -v > %s 2>&1', $null_device ),
+ 'unzip -v > ' . escapeshellarg( $null_device ) . ' 2>&1',
false,
true
);
@@ -97,11 +97,7 @@ private static function is_unzip_available() {
private static function validate_with_unzip( $file_path ) {
// Suppress output - use platform-appropriate null device.
$null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
- $command = sprintf(
- 'unzip -t %s > %s 2>&1',
- escapeshellarg( $file_path ),
- $null_device
- );
+ $command = 'unzip -t ' . escapeshellarg( $file_path ) . ' > ' . escapeshellarg( $null_device ) . ' 2>&1';
$result = WP_CLI::launch(
$command,
@@ -130,6 +126,16 @@ public static function delete_corrupted_file( $file_path ) {
return true;
}
- return @unlink( $file_path );
+ $result = unlink( $file_path );
+
+ // Log if deletion failed, but don't throw an error.
+ if ( ! $result ) {
+ WP_CLI::debug(
+ sprintf( 'Failed to delete corrupted file: %s', $file_path ),
+ 'extension-command'
+ );
+ }
+
+ return $result;
}
}
From 6552b3a5fcb7d558f07f4f85152ce136c85270d5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 6 Nov 2025 09:59:15 +0000
Subject: [PATCH 7/8] Add security comments clarifying safe shell command usage
Added comments explaining that null device paths are hardcoded constants
and safe to use in shell commands, addressing static analysis concerns.
Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
---
src/WP_CLI/PackageValidator.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/WP_CLI/PackageValidator.php b/src/WP_CLI/PackageValidator.php
index bde838b29..875c7a8b7 100644
--- a/src/WP_CLI/PackageValidator.php
+++ b/src/WP_CLI/PackageValidator.php
@@ -76,6 +76,7 @@ private static function is_unzip_available() {
if ( null === $is_available ) {
// Check if unzip is in PATH by trying to get its version.
// Suppress output to avoid cluttering the console.
+ // Note: Redirection to null device is safe as the device path is a hardcoded constant.
$null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
$result = WP_CLI::launch(
'unzip -v > ' . escapeshellarg( $null_device ) . ' 2>&1',
@@ -96,6 +97,7 @@ private static function is_unzip_available() {
*/
private static function validate_with_unzip( $file_path ) {
// Suppress output - use platform-appropriate null device.
+ // Note: Null device path is a hardcoded constant, safe to use in shell commands.
$null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
$command = 'unzip -t ' . escapeshellarg( $file_path ) . ' > ' . escapeshellarg( $null_device ) . ' 2>&1';
From 98e58682f9ef05eaafbeac98b4c7b12c83439fb4 Mon Sep 17 00:00:00 2001
From: Pascal Birchler
Date: Thu, 6 Nov 2025 12:13:42 +0100
Subject: [PATCH 8/8] Fix alignment
---
src/WP_CLI/PackageValidator.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/WP_CLI/PackageValidator.php b/src/WP_CLI/PackageValidator.php
index 875c7a8b7..847243fac 100644
--- a/src/WP_CLI/PackageValidator.php
+++ b/src/WP_CLI/PackageValidator.php
@@ -77,8 +77,8 @@ private static function is_unzip_available() {
// Check if unzip is in PATH by trying to get its version.
// Suppress output to avoid cluttering the console.
// Note: Redirection to null device is safe as the device path is a hardcoded constant.
- $null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
- $result = WP_CLI::launch(
+ $null_device = '\\' === DIRECTORY_SEPARATOR ? 'NUL' : '/dev/null';
+ $result = WP_CLI::launch(
'unzip -v > ' . escapeshellarg( $null_device ) . ' 2>&1',
false,
true