Skip to content

Commit e6c6ab6

Browse files
Copilotswissspidy
andcommitted
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 <[email protected]>
1 parent 58cd3da commit e6c6ab6

8 files changed

+284
-2
lines changed

src/Plugin_Command.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public function __construct() {
7979
}
8080

8181
protected function get_upgrader_class( $force ) {
82-
return $force ? '\\WP_CLI\\DestructivePluginUpgrader' : 'Plugin_Upgrader';
82+
return $force ? '\\WP_CLI\\DestructivePluginUpgrader' : '\\WP_CLI\\ValidatingPluginUpgrader';
8383
}
8484

8585
/**

src/Theme_Command.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public function __construct() {
7575
}
7676

7777
protected function get_upgrader_class( $force ) {
78-
return $force ? '\\WP_CLI\\DestructiveThemeUpgrader' : 'Theme_Upgrader';
78+
return $force ? '\\WP_CLI\\DestructiveThemeUpgrader' : '\\WP_CLI\\ValidatingThemeUpgrader';
7979
}
8080

8181
/**

src/WP_CLI/DestructivePluginUpgrader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* A plugin upgrader class that clears the destination directory.
77
*/
88
class DestructivePluginUpgrader extends \Plugin_Upgrader {
9+
use UpgraderWithValidation;
910

1011
public function install_package( $args = array() ) {
1112
parent::upgrade_strings(); // Needed for the 'remove_old' string.

src/WP_CLI/DestructiveThemeUpgrader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* A theme upgrader class that clears the destination directory.
77
*/
88
class DestructiveThemeUpgrader extends \Theme_Upgrader {
9+
use UpgraderWithValidation;
910

1011
public function install_package( $args = array() ) {
1112
parent::upgrade_strings(); // Needed for the 'remove_old' string.

src/WP_CLI/PackageValidator.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace WP_CLI;
4+
5+
use WP_CLI;
6+
7+
/**
8+
* Validates downloaded package files (zip archives) before installation.
9+
*
10+
* This class provides validation for cached and freshly downloaded package files
11+
* to ensure they are not corrupted. Corrupted files can occur when:
12+
* - A download was interrupted
13+
* - Filesystem issues caused incomplete writes
14+
* - A license expired and the download returned an error message instead of a zip
15+
* - Network issues caused partial downloads
16+
*/
17+
class PackageValidator {
18+
19+
/**
20+
* Minimum acceptable file size in bytes.
21+
* Files smaller than this are considered corrupted.
22+
*/
23+
const MIN_FILE_SIZE = 20;
24+
25+
/**
26+
* Validates a package file to ensure it's a valid zip archive.
27+
*
28+
* Performs the following checks:
29+
* 1. File exists
30+
* 2. File size is at least MIN_FILE_SIZE bytes
31+
* 3. If 'unzip' command is available, validates zip integrity
32+
*
33+
* @param string $file_path Path to the file to validate.
34+
* @return true|\WP_Error True if valid, WP_Error if validation fails.
35+
*/
36+
public static function validate( $file_path ) {
37+
// Check if file exists.
38+
if ( ! file_exists( $file_path ) ) {
39+
return new \WP_Error(
40+
'package_not_found',
41+
sprintf( 'Package file not found: %s', $file_path )
42+
);
43+
}
44+
45+
// Check minimum file size.
46+
$file_size = filesize( $file_path );
47+
if ( false === $file_size || $file_size < self::MIN_FILE_SIZE ) {
48+
return new \WP_Error(
49+
'package_too_small',
50+
sprintf(
51+
'Package file is too small (%d bytes). This usually indicates a corrupted download.',
52+
$file_size ?: 0
53+
)
54+
);
55+
}
56+
57+
// If unzip is available, test the zip file integrity.
58+
if ( self::is_unzip_available() ) {
59+
$validation_result = self::validate_with_unzip( $file_path );
60+
if ( is_wp_error( $validation_result ) ) {
61+
return $validation_result;
62+
}
63+
}
64+
65+
return true;
66+
}
67+
68+
/**
69+
* Checks if the 'unzip' command is available in the system PATH.
70+
*
71+
* @return bool True if unzip is available, false otherwise.
72+
*/
73+
private static function is_unzip_available() {
74+
static $is_available = null;
75+
76+
if ( null === $is_available ) {
77+
// Check if unzip is in PATH by trying to get its version.
78+
$result = WP_CLI::launch(
79+
'unzip -v',
80+
false,
81+
true
82+
);
83+
$is_available = ( 0 === $result->return_code );
84+
}
85+
86+
return $is_available;
87+
}
88+
89+
/**
90+
* Validates zip file integrity using the 'unzip -t' command.
91+
*
92+
* @param string $file_path Path to the zip file.
93+
* @return true|\WP_Error True if valid, WP_Error if validation fails.
94+
*/
95+
private static function validate_with_unzip( $file_path ) {
96+
$result = WP_CLI::launch(
97+
sprintf( 'unzip -t %s', escapeshellarg( $file_path ) ),
98+
false,
99+
true
100+
);
101+
102+
if ( 0 !== $result->return_code ) {
103+
return new \WP_Error(
104+
'package_corrupted',
105+
sprintf(
106+
'Package file failed zip integrity check. This usually indicates a corrupted or incomplete download.'
107+
)
108+
);
109+
}
110+
111+
return true;
112+
}
113+
114+
/**
115+
* Deletes a corrupted package file.
116+
*
117+
* @param string $file_path Path to the file to delete.
118+
* @return bool True if file was deleted or didn't exist, false on failure.
119+
*/
120+
public static function delete_corrupted_file( $file_path ) {
121+
if ( ! file_exists( $file_path ) ) {
122+
return true;
123+
}
124+
125+
return @unlink( $file_path );
126+
}
127+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
namespace WP_CLI;
4+
5+
use WP_CLI;
6+
7+
/**
8+
* Trait for upgraders that validates downloaded packages before installation.
9+
*
10+
* This trait adds package validation to WP_Upgrader subclasses to detect and
11+
* handle corrupted cache files and failed downloads.
12+
*/
13+
trait UpgraderWithValidation {
14+
15+
/**
16+
* Downloads a package with validation.
17+
*
18+
* This method overrides WP_Upgrader::download_package() to add validation
19+
* of the downloaded file before it's used for installation. If validation
20+
* fails, the file is deleted and re-downloaded.
21+
*
22+
* @param string $package The URI of the package.
23+
* @param bool $check_signatures Whether to validate file signatures. Default false.
24+
* @param array $hook_extra Extra arguments to pass to hooked filters. Default empty array.
25+
* @return string|\WP_Error The full path to the downloaded package file, or a WP_Error object.
26+
*/
27+
public function download_package( $package, $check_signatures = false, $hook_extra = array() ) {
28+
// Call parent download_package to get the file (from cache or fresh download).
29+
$download = parent::download_package( $package, $check_signatures, $hook_extra );
30+
31+
// If download failed, return the error.
32+
if ( is_wp_error( $download ) ) {
33+
return $download;
34+
}
35+
36+
// Validate the downloaded file.
37+
$validation = PackageValidator::validate( $download );
38+
39+
// If validation passed, return the file path.
40+
if ( true === $validation ) {
41+
return $download;
42+
}
43+
44+
// Validation failed - log the issue and attempt recovery.
45+
WP_CLI::debug(
46+
sprintf(
47+
'Package validation failed: %s',
48+
$validation->get_error_message()
49+
),
50+
'extension-command'
51+
);
52+
53+
// Delete the corrupted file.
54+
PackageValidator::delete_corrupted_file( $download );
55+
WP_CLI::debug(
56+
'Deleted corrupted package file, attempting fresh download...',
57+
'extension-command'
58+
);
59+
60+
// Try to download again by clearing any cache.
61+
// We need to bypass the cache, which we can do by using a modified package URL.
62+
// However, WP_Upgrader doesn't provide a direct way to do this.
63+
// Instead, we'll use a filter to modify the download behavior.
64+
$retry_download = $this->download_package_retry( $package, $check_signatures, $hook_extra );
65+
66+
// If retry succeeded, validate it again.
67+
if ( ! is_wp_error( $retry_download ) ) {
68+
$retry_validation = PackageValidator::validate( $retry_download );
69+
70+
if ( true === $retry_validation ) {
71+
WP_CLI::debug( 'Fresh download succeeded and validated.', 'extension-command' );
72+
return $retry_download;
73+
}
74+
75+
// Even the retry is corrupted - delete it and give up.
76+
PackageValidator::delete_corrupted_file( $retry_download );
77+
WP_CLI::debug( 'Retry download also failed validation.', 'extension-command' );
78+
}
79+
80+
// Both attempts failed - return an error.
81+
return new \WP_Error(
82+
'package_validation_failed',
83+
'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.'
84+
);
85+
}
86+
87+
/**
88+
* Retries downloading a package, bypassing cache.
89+
*
90+
* This is called when the initial download (which may have come from cache)
91+
* failed validation.
92+
*
93+
* @param string $package The URI of the package.
94+
* @param bool $check_signatures Whether to validate file signatures.
95+
* @param array $hook_extra Extra arguments to pass to hooked filters.
96+
* @return string|\WP_Error The full path to the downloaded package file, or a WP_Error object.
97+
*/
98+
private function download_package_retry( $package, $check_signatures, $hook_extra ) {
99+
// Add a filter to disable caching for this download.
100+
$disable_cache = function( $args, $url ) {
101+
// Disable caching by setting a short timeout and unique filename.
102+
$args['reject_cache'] = true;
103+
return $args;
104+
};
105+
106+
// WP HTTP API doesn't have reject_cache, so we'll use a different approach.
107+
// We'll hook into pre_http_request to force a fresh download.
108+
$force_fresh_download = function( $preempt, $args, $url ) use ( $package ) {
109+
// Only apply to our specific package URL.
110+
if ( $url !== $package ) {
111+
return $preempt;
112+
}
113+
// Return false to proceed with the request (not using preempt).
114+
// The cache is managed by WP-CLI's cache manager, not WP's HTTP API.
115+
return false;
116+
};
117+
118+
add_filter( 'pre_http_request', $force_fresh_download, 10, 3 );
119+
120+
// Attempt the download again.
121+
$result = parent::download_package( $package, $check_signatures, $hook_extra );
122+
123+
remove_filter( 'pre_http_request', $force_fresh_download, 10 );
124+
125+
return $result;
126+
}
127+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace WP_CLI;
4+
5+
/**
6+
* Plugin upgrader with package validation.
7+
*
8+
* This extends WordPress core's Plugin_Upgrader to add validation
9+
* of downloaded packages before installation.
10+
*/
11+
class ValidatingPluginUpgrader extends \Plugin_Upgrader {
12+
use UpgraderWithValidation;
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace WP_CLI;
4+
5+
/**
6+
* Theme upgrader with package validation.
7+
*
8+
* This extends WordPress core's Theme_Upgrader to add validation
9+
* of downloaded packages before installation.
10+
*/
11+
class ValidatingThemeUpgrader extends \Theme_Upgrader {
12+
use UpgraderWithValidation;
13+
}

0 commit comments

Comments
 (0)