wp/wp-admin/includes/class-wp-upgrader.php
changeset 21 48c4eec2b7e6
parent 19 3d72ae0968f4
child 22 8c2e4d02f4ef
--- a/wp/wp-admin/includes/class-wp-upgrader.php	Thu Sep 29 08:06:27 2022 +0200
+++ b/wp/wp-admin/includes/class-wp-upgrader.php	Fri Sep 05 18:40:08 2025 +0200
@@ -48,6 +48,7 @@
  *
  * @since 2.8.0
  */
+#[AllowDynamicProperties]
 class WP_Upgrader {
 
 	/**
@@ -112,6 +113,26 @@
 	public $update_current = 0;
 
 	/**
+	 * Stores the list of plugins or themes added to temporary backup directory.
+	 *
+	 * Used by the rollback functions.
+	 *
+	 * @since 6.3.0
+	 * @var array
+	 */
+	private $temp_backups = array();
+
+	/**
+	 * Stores the list of plugins or themes to be restored from temporary backup directory.
+	 *
+	 * Used by the rollback functions.
+	 *
+	 * @since 6.3.0
+	 * @var array
+	 */
+	private $temp_restores = array();
+
+	/**
 	 * Construct the upgrader with a skin.
 	 *
 	 * @since 2.8.0
@@ -128,34 +149,54 @@
 	}
 
 	/**
-	 * Initialize the upgrader.
+	 * Initializes the upgrader.
 	 *
 	 * This will set the relationship between the skin being used and this upgrader,
 	 * and also add the generic strings to `WP_Upgrader::$strings`.
 	 *
+	 * Additionally, it will schedule a weekly task to clean up the temporary backup directory.
+	 *
 	 * @since 2.8.0
+	 * @since 6.3.0 Added the `schedule_temp_backup_cleanup()` task.
 	 */
 	public function init() {
 		$this->skin->set_upgrader( $this );
 		$this->generic_strings();
+
+		if ( ! wp_installing() ) {
+			$this->schedule_temp_backup_cleanup();
+		}
 	}
 
 	/**
-	 * Add the generic strings to WP_Upgrader::$strings.
+	 * Schedules the cleanup of the temporary backup directory.
+	 *
+	 * @since 6.3.0
+	 */
+	protected function schedule_temp_backup_cleanup() {
+		if ( false === wp_next_scheduled( 'wp_delete_temp_updater_backups' ) ) {
+			wp_schedule_event( time(), 'weekly', 'wp_delete_temp_updater_backups' );
+		}
+	}
+
+	/**
+	 * Adds the generic strings to WP_Upgrader::$strings.
 	 *
 	 * @since 2.8.0
 	 */
 	public function generic_strings() {
-		$this->strings['bad_request']       = __( 'Invalid data provided.' );
-		$this->strings['fs_unavailable']    = __( 'Could not access filesystem.' );
-		$this->strings['fs_error']          = __( 'Filesystem error.' );
-		$this->strings['fs_no_root_dir']    = __( 'Unable to locate WordPress root directory.' );
-		$this->strings['fs_no_content_dir'] = __( 'Unable to locate WordPress content directory (wp-content).' );
+		$this->strings['bad_request']    = __( 'Invalid data provided.' );
+		$this->strings['fs_unavailable'] = __( 'Could not access filesystem.' );
+		$this->strings['fs_error']       = __( 'Filesystem error.' );
+		$this->strings['fs_no_root_dir'] = __( 'Unable to locate WordPress root directory.' );
+		/* translators: %s: Directory name. */
+		$this->strings['fs_no_content_dir'] = sprintf( __( 'Unable to locate WordPress content directory (%s).' ), 'wp-content' );
 		$this->strings['fs_no_plugins_dir'] = __( 'Unable to locate WordPress plugin directory.' );
 		$this->strings['fs_no_themes_dir']  = __( 'Unable to locate WordPress theme directory.' );
 		/* translators: %s: Directory name. */
 		$this->strings['fs_no_folder'] = __( 'Unable to locate needed folder (%s).' );
 
+		$this->strings['no_package']           = __( 'Package not available.' );
 		$this->strings['download_failed']      = __( 'Download failed.' );
 		$this->strings['installing_package']   = __( 'Installing the latest version…' );
 		$this->strings['no_files']             = __( 'The package contains no files.' );
@@ -166,10 +207,19 @@
 
 		$this->strings['maintenance_start'] = __( 'Enabling Maintenance mode…' );
 		$this->strings['maintenance_end']   = __( 'Disabling Maintenance mode…' );
+
+		/* translators: %s: upgrade-temp-backup */
+		$this->strings['temp_backup_mkdir_failed'] = sprintf( __( 'Could not create the %s directory.' ), 'upgrade-temp-backup' );
+		/* translators: %s: upgrade-temp-backup */
+		$this->strings['temp_backup_move_failed'] = sprintf( __( 'Could not move the old version to the %s directory.' ), 'upgrade-temp-backup' );
+		/* translators: %s: The plugin or theme slug. */
+		$this->strings['temp_backup_restore_failed'] = __( 'Could not restore the original version of %s.' );
+		/* translators: %s: The plugin or theme slug. */
+		$this->strings['temp_backup_delete_failed'] = __( 'Could not delete the temporary backup directory for %s.' );
 	}
 
 	/**
-	 * Connect to the filesystem.
+	 * Connects to the filesystem.
 	 *
 	 * @since 2.8.0
 	 *
@@ -241,7 +291,7 @@
 	}
 
 	/**
-	 * Download a package.
+	 * Downloads a package.
 	 *
 	 * @since 2.8.0
 	 * @since 5.2.0 Added the `$check_signatures` parameter.
@@ -291,7 +341,7 @@
 	}
 
 	/**
-	 * Unpack a compressed package file.
+	 * Unpacks a compressed package file.
 	 *
 	 * @since 2.8.0
 	 *
@@ -307,6 +357,10 @@
 
 		$this->skin->feedback( 'unpack_package' );
 
+		if ( ! $wp_filesystem->wp_content_dir() ) {
+			return new WP_Error( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] );
+		}
+
 		$upgrade_folder = $wp_filesystem->wp_content_dir() . 'upgrade/';
 
 		// Clean up contents of upgrade directory beforehand.
@@ -345,7 +399,7 @@
 	}
 
 	/**
-	 * Flatten the results of WP_Filesystem_Base::dirlist() for iterating over.
+	 * Flattens the results of WP_Filesystem_Base::dirlist() for iterating over.
 	 *
 	 * @since 4.9.0
 	 * @access protected
@@ -428,6 +482,7 @@
 	 * clear out the destination folder if it already exists.
 	 *
 	 * @since 2.8.0
+	 * @since 6.2.0 Use move_dir() instead of copy_dir() when possible.
 	 *
 	 * @global WP_Filesystem_Base $wp_filesystem        WordPress filesystem subclass.
 	 * @global array              $wp_theme_directories
@@ -469,9 +524,14 @@
 		$destination       = $args['destination'];
 		$clear_destination = $args['clear_destination'];
 
-		set_time_limit( 300 );
+		if ( function_exists( 'set_time_limit' ) ) {
+			set_time_limit( 300 );
+		}
 
-		if ( empty( $source ) || empty( $destination ) ) {
+		if (
+			( ! is_string( $source ) || '' === $source || trim( $source ) !== $source ) ||
+			( ! is_string( $destination ) || '' === $destination || trim( $destination ) !== $destination )
+		) {
 			return new WP_Error( 'bad_request', $this->strings['bad_request'] );
 		}
 		$this->skin->feedback( 'installing_package' );
@@ -508,8 +568,10 @@
 			// There are no files?
 			return new WP_Error( 'incompatible_archive_empty', $this->strings['incompatible_archive'], $this->strings['no_files'] );
 		} else {
-			// It's only a single file, the upgrader will use the folder name of this file as the destination folder.
-			// Folder name is based on zip filename.
+			/*
+			 * It's only a single file, the upgrader will use the folder name of this file as the destination folder.
+			 * Folder name is based on zip filename.
+			 */
 			$source = trailingslashit( $args['source'] );
 		}
 
@@ -530,6 +592,16 @@
 			return $source;
 		}
 
+		if ( ! empty( $args['hook_extra']['temp_backup'] ) ) {
+			$temp_backup = $this->move_to_temp_backup_dir( $args['hook_extra']['temp_backup'] );
+
+			if ( is_wp_error( $temp_backup ) ) {
+				return $temp_backup;
+			}
+
+			$this->temp_backups[] = $args['hook_extra']['temp_backup'];
+		}
+
 		// Has the source location changed? If so, we need a new source_files list.
 		if ( $source !== $remote_source ) {
 			$source_files = array_keys( $wp_filesystem->dirlist( $source ) );
@@ -576,8 +648,10 @@
 				return $removed;
 			}
 		} elseif ( $args['abort_if_destination_exists'] && $wp_filesystem->exists( $remote_destination ) ) {
-			// If we're not clearing the destination folder and something exists there already, bail.
-			// But first check to see if there are actually any files in the folder.
+			/*
+			 * If we're not clearing the destination folder and something exists there already, bail.
+			 * But first check to see if there are actually any files in the folder.
+			 */
 			$_files = $wp_filesystem->dirlist( $remote_destination );
 			if ( ! empty( $_files ) ) {
 				$wp_filesystem->delete( $remote_source, true ); // Clear out the source files.
@@ -585,25 +659,38 @@
 			}
 		}
 
-		// Create destination if needed.
-		if ( ! $wp_filesystem->exists( $remote_destination ) ) {
-			if ( ! $wp_filesystem->mkdir( $remote_destination, FS_CHMOD_DIR ) ) {
-				return new WP_Error( 'mkdir_failed_destination', $this->strings['mkdir_failed'], $remote_destination );
+		/*
+		 * If 'clear_working' is false, the source should not be removed, so use copy_dir() instead.
+		 *
+		 * Partial updates, like language packs, may want to retain the destination.
+		 * If the destination exists or has contents, this may be a partial update,
+		 * and the destination should not be removed, so use copy_dir() instead.
+		 */
+		if ( $args['clear_working']
+			&& (
+				// Destination does not exist or has no contents.
+				! $wp_filesystem->exists( $remote_destination )
+				|| empty( $wp_filesystem->dirlist( $remote_destination ) )
+			)
+		) {
+			$result = move_dir( $source, $remote_destination, true );
+		} else {
+			// Create destination if needed.
+			if ( ! $wp_filesystem->exists( $remote_destination ) ) {
+				if ( ! $wp_filesystem->mkdir( $remote_destination, FS_CHMOD_DIR ) ) {
+					return new WP_Error( 'mkdir_failed_destination', $this->strings['mkdir_failed'], $remote_destination );
+				}
 			}
+			$result = copy_dir( $source, $remote_destination );
 		}
 
-		// Copy new version of item into place.
-		$result = copy_dir( $source, $remote_destination );
-		if ( is_wp_error( $result ) ) {
-			if ( $args['clear_working'] ) {
-				$wp_filesystem->delete( $remote_source, true );
-			}
-			return $result;
+		// Clear the working directory?
+		if ( $args['clear_working'] ) {
+			$wp_filesystem->delete( $remote_source, true );
 		}
 
-		// Clear the working folder?
-		if ( $args['clear_working'] ) {
-			$wp_filesystem->delete( $remote_source, true );
+		if ( is_wp_error( $result ) ) {
+			return $result;
 		}
 
 		$destination_name = basename( str_replace( $local_destination, '', $destination ) );
@@ -634,7 +721,7 @@
 	}
 
 	/**
-	 * Run an upgrade/installation.
+	 * Runs an upgrade/installation.
 	 *
 	 * Attempts to download the package (if it is not a local file), unpack it, and
 	 * install it in the destination folder.
@@ -741,10 +828,12 @@
 		 * Download the package. Note: If the package is the full path
 		 * to an existing local file, it will be returned untouched.
 		 */
-		$download = $this->download_package( $options['package'], true, $options['hook_extra'] );
+		$download = $this->download_package( $options['package'], false, $options['hook_extra'] );
 
-		// Allow for signature soft-fail.
-		// WARNING: This may be removed in the future.
+		/*
+		 * Allow for signature soft-fail.
+		 * WARNING: This may be removed in the future.
+		 */
 		if ( is_wp_error( $download ) && $download->get_error_data( 'softfail-filename' ) ) {
 
 			// Don't output the 'no signature could be found' failure message for now.
@@ -810,7 +899,24 @@
 		$result = apply_filters( 'upgrader_install_package_result', $result, $options['hook_extra'] );
 
 		$this->skin->set_result( $result );
+
 		if ( is_wp_error( $result ) ) {
+			// An automatic plugin update will have already performed its rollback.
+			if ( ! empty( $options['hook_extra']['temp_backup'] ) ) {
+				$this->temp_restores[] = $options['hook_extra']['temp_backup'];
+
+				/*
+				 * Restore the backup on shutdown.
+				 * Actions running on `shutdown` are immune to PHP timeouts,
+				 * so in case the failure was due to a PHP timeout,
+				 * it will still be able to properly restore the previous version.
+				 *
+				 * Zero arguments are accepted as a string can sometimes be passed
+				 * internally during actions, causing an error because
+				 * `WP_Upgrader::restore_temp_backup()` expects an array.
+				 */
+				add_action( 'shutdown', array( $this, 'restore_temp_backup' ), 10, 0 );
+			}
 			$this->skin->error( $result );
 
 			if ( ! method_exists( $this->skin, 'hide_process_failed' ) || ! $this->skin->hide_process_failed( $result ) ) {
@@ -823,6 +929,12 @@
 
 		$this->skin->after();
 
+		// Clean up the backup kept in the temporary backup directory.
+		if ( ! empty( $options['hook_extra']['temp_backup'] ) ) {
+			// Delete the backup on `shutdown` to avoid a PHP timeout.
+			add_action( 'shutdown', array( $this, 'delete_temp_backup' ), 100, 0 );
+		}
+
 		if ( ! $options['is_multi'] ) {
 
 			/**
@@ -864,7 +976,7 @@
 	}
 
 	/**
-	 * Toggle maintenance mode for the site.
+	 * Toggles maintenance mode for the site.
 	 *
 	 * Creates/deletes the maintenance file to enable/disable maintenance mode.
 	 *
@@ -876,15 +988,25 @@
 	 */
 	public function maintenance_mode( $enable = false ) {
 		global $wp_filesystem;
+
+		if ( ! $wp_filesystem ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+			WP_Filesystem();
+		}
+
 		$file = $wp_filesystem->abspath() . '.maintenance';
 		if ( $enable ) {
-			$this->skin->feedback( 'maintenance_start' );
+			if ( ! wp_doing_cron() ) {
+				$this->skin->feedback( 'maintenance_start' );
+			}
 			// Create maintenance file to signal that we are upgrading.
 			$maintenance_string = '<?php $upgrading = ' . time() . '; ?>';
 			$wp_filesystem->delete( $file );
 			$wp_filesystem->put_contents( $file, $maintenance_string, FS_CHMOD_FILE );
 		} elseif ( ! $enable && $wp_filesystem->exists( $file ) ) {
-			$this->skin->feedback( 'maintenance_end' );
+			if ( ! wp_doing_cron() ) {
+				$this->skin->feedback( 'maintenance_end' );
+			}
 			$wp_filesystem->delete( $file );
 		}
 	}
@@ -894,6 +1016,8 @@
 	 *
 	 * @since 4.5.0
 	 *
+	 * @global wpdb $wpdb The WordPress database abstraction object.
+	 *
 	 * @param string $lock_name       The name of this unique lock.
 	 * @param int    $release_timeout Optional. The duration in seconds to respect an existing lock.
 	 *                                Default: 1 hour.
@@ -907,7 +1031,7 @@
 		$lock_option = $lock_name . '.lock';
 
 		// Try to lock.
-		$lock_result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'no') /* LOCK */", $lock_option, time() ) );
+		$lock_result = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `$wpdb->options` ( `option_name`, `option_value`, `autoload` ) VALUES (%s, %s, 'off') /* LOCK */", $lock_option, time() ) );
 
 		if ( ! $lock_result ) {
 			$lock_result = get_option( $lock_option );
@@ -947,6 +1071,201 @@
 	public static function release_lock( $lock_name ) {
 		return delete_option( $lock_name . '.lock' );
 	}
+
+	/**
+	 * Moves the plugin or theme being updated into a temporary backup directory.
+	 *
+	 * @since 6.3.0
+	 *
+	 * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
+	 *
+	 * @param string[] $args {
+	 *     Array of data for the temporary backup.
+	 *
+	 *     @type string $slug Plugin or theme slug.
+	 *     @type string $src  Path to the root directory for plugins or themes.
+	 *     @type string $dir  Destination subdirectory name. Accepts 'plugins' or 'themes'.
+	 * }
+	 *
+	 * @return bool|WP_Error True on success, false on early exit, otherwise WP_Error.
+	 */
+	public function move_to_temp_backup_dir( $args ) {
+		global $wp_filesystem;
+
+		if ( empty( $args['slug'] ) || empty( $args['src'] ) || empty( $args['dir'] ) ) {
+			return false;
+		}
+
+		/*
+		 * Skip any plugin that has "." as its slug.
+		 * A slug of "." will result in a `$src` value ending in a period.
+		 *
+		 * On Windows, this will cause the 'plugins' folder to be moved,
+		 * and will cause a failure when attempting to call `mkdir()`.
+		 */
+		if ( '.' === $args['slug'] ) {
+			return false;
+		}
+
+		if ( ! $wp_filesystem->wp_content_dir() ) {
+			return new WP_Error( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] );
+		}
+
+		$dest_dir = $wp_filesystem->wp_content_dir() . 'upgrade-temp-backup/';
+		$sub_dir  = $dest_dir . $args['dir'] . '/';
+
+		// Create the temporary backup directory if it does not exist.
+		if ( ! $wp_filesystem->is_dir( $sub_dir ) ) {
+			if ( ! $wp_filesystem->is_dir( $dest_dir ) ) {
+				$wp_filesystem->mkdir( $dest_dir, FS_CHMOD_DIR );
+			}
+
+			if ( ! $wp_filesystem->mkdir( $sub_dir, FS_CHMOD_DIR ) ) {
+				// Could not create the backup directory.
+				return new WP_Error( 'fs_temp_backup_mkdir', $this->strings['temp_backup_mkdir_failed'] );
+			}
+		}
+
+		$src_dir = $wp_filesystem->find_folder( $args['src'] );
+		$src     = trailingslashit( $src_dir ) . $args['slug'];
+		$dest    = $dest_dir . trailingslashit( $args['dir'] ) . $args['slug'];
+
+		// Delete the temporary backup directory if it already exists.
+		if ( $wp_filesystem->is_dir( $dest ) ) {
+			$wp_filesystem->delete( $dest, true );
+		}
+
+		// Move to the temporary backup directory.
+		$result = move_dir( $src, $dest, true );
+		if ( is_wp_error( $result ) ) {
+			return new WP_Error( 'fs_temp_backup_move', $this->strings['temp_backup_move_failed'] );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Restores the plugin or theme from temporary backup.
+	 *
+	 * @since 6.3.0
+	 * @since 6.6.0 Added the `$temp_backups` parameter.
+	 *
+	 * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
+	 *
+	 * @param array[] $temp_backups {
+	 *     Optional. An array of temporary backups.
+	 *
+	 *     @type array ...$0 {
+	 *         Information about the backup.
+	 *
+	 *         @type string $dir  The temporary backup location in the upgrade-temp-backup directory.
+	 *         @type string $slug The item's slug.
+	 *         @type string $src  The directory where the original is stored. For example, `WP_PLUGIN_DIR`.
+	 *     }
+	 * }
+	 * @return bool|WP_Error True on success, false on early exit, otherwise WP_Error.
+	 */
+	public function restore_temp_backup( array $temp_backups = array() ) {
+		global $wp_filesystem;
+
+		$errors = new WP_Error();
+
+		if ( empty( $temp_backups ) ) {
+			$temp_backups = $this->temp_restores;
+		}
+
+		foreach ( $temp_backups as $args ) {
+			if ( empty( $args['slug'] ) || empty( $args['src'] ) || empty( $args['dir'] ) ) {
+				return false;
+			}
+
+			if ( ! $wp_filesystem->wp_content_dir() ) {
+				$errors->add( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] );
+				return $errors;
+			}
+
+			$src      = $wp_filesystem->wp_content_dir() . 'upgrade-temp-backup/' . $args['dir'] . '/' . $args['slug'];
+			$dest_dir = $wp_filesystem->find_folder( $args['src'] );
+			$dest     = trailingslashit( $dest_dir ) . $args['slug'];
+
+			if ( $wp_filesystem->is_dir( $src ) ) {
+				// Cleanup.
+				if ( $wp_filesystem->is_dir( $dest ) && ! $wp_filesystem->delete( $dest, true ) ) {
+					$errors->add(
+						'fs_temp_backup_delete',
+						sprintf( $this->strings['temp_backup_restore_failed'], $args['slug'] )
+					);
+					continue;
+				}
+
+				// Move it.
+				$result = move_dir( $src, $dest, true );
+				if ( is_wp_error( $result ) ) {
+					$errors->add(
+						'fs_temp_backup_delete',
+						sprintf( $this->strings['temp_backup_restore_failed'], $args['slug'] )
+					);
+					continue;
+				}
+			}
+		}
+
+		return $errors->has_errors() ? $errors : true;
+	}
+
+	/**
+	 * Deletes a temporary backup.
+	 *
+	 * @since 6.3.0
+	 * @since 6.6.0 Added the `$temp_backups` parameter.
+	 *
+	 * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass.
+	 *
+	 * @param array[] $temp_backups {
+	 *     Optional. An array of temporary backups.
+	 *
+	 *     @type array ...$0 {
+	 *         Information about the backup.
+	 *
+	 *         @type string $dir  The temporary backup location in the upgrade-temp-backup directory.
+	 *         @type string $slug The item's slug.
+	 *         @type string $src  The directory where the original is stored. For example, `WP_PLUGIN_DIR`.
+	 *     }
+	 * }
+	 * @return bool|WP_Error True on success, false on early exit, otherwise WP_Error.
+	 */
+	public function delete_temp_backup( array $temp_backups = array() ) {
+		global $wp_filesystem;
+
+		$errors = new WP_Error();
+
+		if ( empty( $temp_backups ) ) {
+			$temp_backups = $this->temp_backups;
+		}
+
+		foreach ( $temp_backups as $args ) {
+			if ( empty( $args['slug'] ) || empty( $args['dir'] ) ) {
+				return false;
+			}
+
+			if ( ! $wp_filesystem->wp_content_dir() ) {
+				$errors->add( 'fs_no_content_dir', $this->strings['fs_no_content_dir'] );
+				return $errors;
+			}
+
+			$temp_backup_dir = $wp_filesystem->wp_content_dir() . "upgrade-temp-backup/{$args['dir']}/{$args['slug']}";
+
+			if ( ! $wp_filesystem->delete( $temp_backup_dir, true ) ) {
+				$errors->add(
+					'temp_backup_delete_failed',
+					sprintf( $this->strings['temp_backup_delete_failed'], $args['slug'] )
+				);
+				continue;
+			}
+		}
+
+		return $errors->has_errors() ? $errors : true;
+	}
 }
 
 /** Plugin_Upgrader class */