Skip to content
Closed
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
7 changes: 6 additions & 1 deletion phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@
<rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound">
<exclude-pattern>*/src/WP_CLI/Fetchers/(Plugin|Theme)\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/CommandWithUpgrade\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/(CommandWith|DestructivePlugin|DestructiveTheme)Upgrader\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/DestructivePluginUpgrader\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/DestructiveThemeUpgrader\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/ValidatingPluginUpgrader\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/ValidatingThemeUpgrader\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/PackageValidator\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/UpgraderWithValidation\.php$</exclude-pattern>
<exclude-pattern>*/src/WP_CLI/Parse(Plugin|Theme)NameInput\.php$</exclude-pattern>
</rule>

Expand Down
2 changes: 1 addition & 1 deletion src/Plugin_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Theme_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/WP_CLI/DestructivePluginUpgrader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/WP_CLI/DestructiveThemeUpgrader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
143 changes: 143 additions & 0 deletions src/WP_CLI/PackageValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

namespace WP_CLI;

use WP_CLI;

/**
* Validates downloaded package files (zip archives) before installation.
*
* This class provides validation for cached and freshly downloaded package files
* to ensure they are not corrupted. Corrupted files can occur when:
* - A download was interrupted
* - Filesystem issues caused incomplete writes
* - A license expired and the download returned an error message instead of a zip
* - Network issues caused partial downloads
*/
class PackageValidator {

/**
* Minimum acceptable file size in bytes.
* Files smaller than this are considered corrupted.
*/
const MIN_FILE_SIZE = 20;

/**
* Validates a package file to ensure it's a valid zip archive.
*
* Performs the following checks:
* 1. File exists
* 2. File size is at least MIN_FILE_SIZE bytes
* 3. If 'unzip' command is available, validates zip integrity
*
* @param string $file_path Path to the file to validate.
* @return true|\WP_Error True if valid, WP_Error if validation fails.
*/
public static function validate( $file_path ) {
// Check if file exists.
if ( ! file_exists( $file_path ) ) {
return new \WP_Error(
'package_not_found',
sprintf( 'Package file not found: %s', $file_path )
);
}

// Check minimum file size.
$file_size = filesize( $file_path );
if ( false === $file_size || $file_size < self::MIN_FILE_SIZE ) {
return new \WP_Error(
'package_too_small',
sprintf(
'Package file is too small (%d bytes). This usually indicates a corrupted download.',
$file_size ?: 0
)
);
}

// If unzip is available, test the zip file integrity.
if ( self::is_unzip_available() ) {
$validation_result = self::validate_with_unzip( $file_path );
if ( is_wp_error( $validation_result ) ) {
return $validation_result;
}
}

return true;
}

/**
* Checks if the 'unzip' command is available in the system PATH.
*
* @return bool True if unzip is available, false otherwise.
*/
private static function is_unzip_available() {
static $is_available = null;

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',
false,
true
);
$is_available = ( 0 === $result->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 ) {
// 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';

$result = WP_CLI::launch(
$command,
false,
true
);

if ( 0 !== $result->return_code ) {
return new \WP_Error(
'package_corrupted',
'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;
}

$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;
}
}
70 changes: 70 additions & 0 deletions src/WP_CLI/UpgraderWithValidation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace WP_CLI;

use WP_CLI;

/**
* Trait for upgraders that validates downloaded packages before installation.
*
* This trait adds package validation to WP_Upgrader subclasses to detect and
* handle corrupted cache files and failed downloads.
*/
trait UpgraderWithValidation {

/**
* Downloads a package with validation.
*
* This method overrides WP_Upgrader::download_package() to add validation
* of the downloaded file before it's used for installation. If validation
* 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.
* @param array $hook_extra Extra arguments to pass to hooked filters. Default empty array.
* @return string|\WP_Error The full path to the downloaded package file, or a WP_Error object.
*/
public function download_package( $package, $check_signatures = false, $hook_extra = array() ) {
// Call parent download_package to get the file (from cache or fresh download).
$download = parent::download_package( $package, $check_signatures, $hook_extra );

// If download failed, return the error.
if ( is_wp_error( $download ) ) {
return $download;
}

// Validate the downloaded file.
$validation = PackageValidator::validate( $download );

// If validation passed, return the file path.
if ( true === $validation ) {
return $download;
}

// Validation failed - log the issue and clean up.
WP_CLI::debug(
sprintf(
'Package validation failed: %s',
$validation->get_error_message()
),
'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'
);
}

// Return a detailed error message.
return new \WP_Error(
'package_validation_failed',
sprintf(
'Downloaded package failed validation (%s). The corrupted file has been removed from cache. Please try the command again.',
$validation->get_error_message()
)
);
}
}
13 changes: 13 additions & 0 deletions src/WP_CLI/ValidatingPluginUpgrader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace WP_CLI;

/**
* Plugin upgrader with package validation.
*
* This extends WordPress core's Plugin_Upgrader to add validation
* of downloaded packages before installation.
*/
class ValidatingPluginUpgrader extends \Plugin_Upgrader {
use UpgraderWithValidation;
}
13 changes: 13 additions & 0 deletions src/WP_CLI/ValidatingThemeUpgrader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace WP_CLI;

/**
* Theme upgrader with package validation.
*
* This extends WordPress core's Theme_Upgrader to add validation
* of downloaded packages before installation.
*/
class ValidatingThemeUpgrader extends \Theme_Upgrader {
use UpgraderWithValidation;
}
Loading