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