Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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';

Check warning on line 80 in src/WP_CLI/PackageValidator.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Equals sign not aligned with surrounding assignments; expected 2 spaces but found 1 space
$result = WP_CLI::launch(

Check warning on line 81 in src/WP_CLI/PackageValidator.php

View workflow job for this annotation

GitHub Actions / code-quality / PHPCS

Equals sign not aligned with surrounding assignments; expected 7 spaces but found 6 spaces
Comment thread
swissspidy marked this conversation as resolved.
Outdated
'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