From 86dfc3cc9d603c6ca0aae284388d4bf6ed7501ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:37:54 +0000 Subject: [PATCH 1/7] Initial plan From 813a6489a6cef26bb060cfecaca0436db97fa464 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:43:20 +0000 Subject: [PATCH 2/7] Refactor SearchReplacer functionality into Search_Replace_Command Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Search_Replace_Command.php | 215 +++++++++++++++++++++++++++++++-- src/WP_CLI/SearchReplacer.php | 4 + 2 files changed, 211 insertions(+), 8 deletions(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index 97a86dd8..d7fc2dd3 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -3,7 +3,6 @@ use cli\Colors; use cli\Table; use WP_CLI\Iterators; -use WP_CLI\SearchReplacer; use WP_CLI\Utils; use function cli\safe_substr; @@ -119,6 +118,16 @@ class Search_Replace_Command extends WP_CLI_Command { */ private $start_time; + /** + * @var string[] + */ + private $replacer_log_data = array(); + + /** + * @var int + */ + private $max_recursion; + /** * Searches/replaces strings in the database. * @@ -555,7 +564,6 @@ private function php_export_table( $table, $old, $new ) { 'chunk_size' => $chunk_size, ); - $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, false, $this->regex_limit ); $col_counts = array_fill_keys( $all_columns, 0 ); if ( $this->verbose && 'table' === $this->format ) { $this->start_time = microtime( true ); @@ -568,7 +576,7 @@ private function php_export_table( $table, $old, $new ) { foreach ( $all_columns as $col ) { $value = $row->$col; if ( $value && ! in_array( $col, $primary_keys, true ) && ! in_array( $col, $this->skip_columns, true ) ) { - $new_value = $replacer->run( $value ); + $new_value = $this->run_search_replace( $value, $old, $new, false, false ); if ( $new_value !== $value ) { ++$col_counts[ $col ]; $value = $new_value; @@ -631,8 +639,8 @@ private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { private function php_handle_col( $col, $primary_keys, $table, $old, $new ) { global $wpdb; - $count = 0; - $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, null !== $this->log_handle, $this->regex_limit ); + $count = 0; + $logging = null !== $this->log_handle; $table_sql = self::esc_sql_ident( $table ); $col_sql = self::esc_sql_ident( $col ); @@ -676,7 +684,7 @@ static function ( $key ) { continue; } - $value = $replacer->run( $col_value ); + $value = $this->run_search_replace( $col_value, $old, $new, false, $logging ); if ( $value === $col_value ) { continue; @@ -689,8 +697,8 @@ static function ( $key ) { } if ( $this->log_handle ) { - $this->log_php_diff( $col, $keys, $table, $old, $new, $replacer->get_log_data() ); - $replacer->clear_log_data(); + $this->log_php_diff( $col, $keys, $table, $old, $new, $this->get_replacer_log_data() ); + $this->clear_replacer_log_data(); } ++$count; @@ -1154,4 +1162,195 @@ private function log_write( $col, $keys, $table, $old_bits, $new_bits ) { fwrite( $this->log_handle, "{$table_column_id_log}\n{$old_log}\n{$new_log}\n" ); } + + /** + * Run a search/replace on data. + * + * @param array|string $data The data to operate on. + * @param string $old String we're looking to replace. + * @param string $new What we want it to be replaced with. + * @param bool $serialised Does the value of $data need to be unserialized? + * @param bool $logging Whether to log changes. + * + * @return array|string The original data with all elements replaced as needed. + */ + private function run_search_replace( $data, $old, $new, $serialised = false, $logging = false ) { + // Initialize max_recursion if not already set + if ( ! isset( $this->max_recursion ) ) { + // Get the XDebug nesting level. Will be zero (no limit) if no value is set + $this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) ); + } + + return $this->run_search_replace_recursively( $data, $old, $new, $serialised, $logging ); + } + + /** + * Recursively run a search/replace on data. + * + * @param array|string $data The data to operate on. + * @param string $old String we're looking to replace. + * @param string $new What we want it to be replaced with. + * @param bool $serialised Does the value of $data need to be unserialized? + * @param bool $logging Whether to log changes. + * @param int $recursion_level Current recursion depth within the original data. + * @param array $visited_data Data that has been seen in previous recursion iterations. + * + * @return array|string The original data with all elements replaced as needed. + */ + private function run_search_replace_recursively( $data, $old, $new, $serialised = false, $logging = false, $recursion_level = 0, $visited_data = array() ) { + // some unseriliased data cannot be re-serialised eg. SimpleXMLElements + try { + + if ( $this->recurse_objects ) { + + // If we've reached the maximum recursion level, short circuit + if ( 0 !== $this->max_recursion && $recursion_level >= $this->max_recursion ) { + return $data; + } + + if ( is_array( $data ) || is_object( $data ) ) { + // If we've seen this exact object or array before, short circuit + if ( in_array( $data, $visited_data, true ) ) { + return $data; // Avoid infinite loops when there's a cycle + } + // Add this data to the list of + $visited_data[] = $data; + } + } + + try { + // The error suppression operator is not enough in some cases, so we disable + // reporting of notices and warnings as well. + $error_reporting = error_reporting(); + error_reporting( $error_reporting & ~E_NOTICE & ~E_WARNING ); + $unserialized = is_string( $data ) ? @unserialize( $data ) : false; + error_reporting( $error_reporting ); + + } catch ( \TypeError $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.typeerrorFound + // This type error is thrown when trying to unserialize a string that does not fit the + // type declarations of the properties it is supposed to fill. + // This type checking was introduced with PHP 8.1. + // See https://github.com/wp-cli/search-replace-command/issues/191 + \WP_CLI::warning( + sprintf( + 'Skipping an inconvertible serialized object: "%s", replacements might not be complete. Reason: %s.', + $data, + $exception->getMessage() + ) + ); + + throw new \Exception( $exception->getMessage(), $exception->getCode(), $exception ); + } + + if ( false !== $unserialized ) { + $data = $this->run_search_replace_recursively( $unserialized, $old, $new, true, $logging, $recursion_level + 1 ); + } elseif ( is_array( $data ) ) { + $keys = array_keys( $data ); + foreach ( $keys as $key ) { + $data[ $key ] = $this->run_search_replace_recursively( $data[ $key ], $old, $new, false, $logging, $recursion_level + 1, $visited_data ); + } + } elseif ( $this->recurse_objects && ( is_object( $data ) || $data instanceof \__PHP_Incomplete_Class ) ) { + if ( $data instanceof \__PHP_Incomplete_Class ) { + $array = new \ArrayObject( $data ); + \WP_CLI::warning( + sprintf( + 'Skipping an uninitialized class "%s", replacements might not be complete.', + $array['__PHP_Incomplete_Class_Name'] + ) + ); + } else { + try { + foreach ( $data as $key => $value ) { + $data->$key = $this->run_search_replace_recursively( $value, $old, $new, false, $logging, $recursion_level + 1, $visited_data ); + } + } catch ( \Error $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound + // This error is thrown when the object that was unserialized cannot be iterated upon. + // The most notable reason is an empty `mysqli_result` object which is then considered to be "already closed". + // See https://github.com/wp-cli/search-replace-command/pull/192#discussion_r1412310179 + \WP_CLI::warning( + sprintf( + 'Skipping an inconvertible serialized object of type "%s", replacements might not be complete. Reason: %s.', + is_object( $data ) ? get_class( $data ) : gettype( $data ), + $exception->getMessage() + ) + ); + + throw new \Exception( $exception->getMessage(), $exception->getCode(), $exception ); + } + } + } elseif ( is_string( $data ) ) { + if ( $logging ) { + $old_data = $data; + } + if ( $this->regex ) { + $search_regex = $this->regex_delimiter; + $search_regex .= $old; + $search_regex .= $this->regex_delimiter; + $search_regex .= $this->regex_flags; + + $result = preg_replace( $search_regex, $new, $data, $this->regex_limit ); + if ( null === $result || PREG_NO_ERROR !== preg_last_error() ) { + \WP_CLI::warning( + sprintf( + 'The provided regular expression threw a PCRE error - %s', + $this->preg_error_message( $result ) + ) + ); + } + $data = $result; + } else { + $data = str_replace( $old, $new, $data ); + } + if ( $logging && $old_data !== $data ) { + $this->replacer_log_data[] = $old_data; + } + } + + if ( $serialised ) { + return serialize( $data ); + } + } catch ( \Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Intentionally empty. + + } + + return $data; + } + + /** + * Gets existing data saved for logging. + * + * @return string[] Array of data strings, prior to replacements. + */ + private function get_replacer_log_data() { + return $this->replacer_log_data; + } + + /** + * Clears data stored for logging. + */ + private function clear_replacer_log_data() { + $this->replacer_log_data = array(); + } + + /** + * Get the PCRE error constant name from an error value. + * + * @param integer $error Error code. + * @return string Error constant name. + */ + private function preg_error_message( $error ) { + static $error_names = null; + + if ( null === $error_names ) { + $definitions = get_defined_constants( true ); + $pcre_constants = array_key_exists( 'pcre', $definitions ) + ? $definitions['pcre'] + : array(); + $error_names = array_flip( $pcre_constants ); + } + + return isset( $error_names[ $error ] ) + ? $error_names[ $error ] + : ''; + } } diff --git a/src/WP_CLI/SearchReplacer.php b/src/WP_CLI/SearchReplacer.php index 8c5ee951..a85cb323 100644 --- a/src/WP_CLI/SearchReplacer.php +++ b/src/WP_CLI/SearchReplacer.php @@ -5,6 +5,10 @@ use ArrayObject; use Exception; +/** + * @deprecated 2.2.0 This class is deprecated. Its functionality has been moved into Search_Replace_Command. + * The class is kept for backward compatibility but should not be used in new code. + */ class SearchReplacer { /** From 1fa8f8ee1b651227a973adbc0d38f1702dea004b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:46:10 +0000 Subject: [PATCH 3/7] Fix incomplete comment in run_search_replace_recursively Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Search_Replace_Command.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index d7fc2dd3..400612c5 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -1213,7 +1213,7 @@ private function run_search_replace_recursively( $data, $old, $new, $serialised if ( in_array( $data, $visited_data, true ) ) { return $data; // Avoid infinite loops when there's a cycle } - // Add this data to the list of + // Add this data to the list of visited data to prevent infinite loops $visited_data[] = $data; } } From 0a0164b6376ade03971609f3f8e1885ce752849b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:47:16 +0000 Subject: [PATCH 4/7] Add clarifying comments for boolean parameters Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Search_Replace_Command.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index 400612c5..d790b0f1 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -576,6 +576,7 @@ private function php_export_table( $table, $old, $new ) { foreach ( $all_columns as $col ) { $value = $row->$col; if ( $value && ! in_array( $col, $primary_keys, true ) && ! in_array( $col, $this->skip_columns, true ) ) { + // Not serialised, not logging $new_value = $this->run_search_replace( $value, $old, $new, false, false ); if ( $new_value !== $value ) { ++$col_counts[ $col ]; @@ -684,6 +685,7 @@ static function ( $key ) { continue; } + // Not serialised, but may need logging $value = $this->run_search_replace( $col_value, $old, $new, false, $logging ); if ( $value === $col_value ) { From 369242a459f85b56580e6ec086802e143c03ef88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:48:07 +0000 Subject: [PATCH 5/7] Fix spelling: unseriliased -> unserialized Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Search_Replace_Command.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index d790b0f1..dc43e3c5 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -1200,7 +1200,7 @@ private function run_search_replace( $data, $old, $new, $serialised = false, $lo * @return array|string The original data with all elements replaced as needed. */ private function run_search_replace_recursively( $data, $old, $new, $serialised = false, $logging = false, $recursion_level = 0, $visited_data = array() ) { - // some unseriliased data cannot be re-serialised eg. SimpleXMLElements + // some unserialized data cannot be re-serialized eg. SimpleXMLElements try { if ( $this->recurse_objects ) { From cf02fb8d42484cafc78652eaca06037fcfd36077 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Feb 2026 02:07:19 +0000 Subject: [PATCH 6/7] Update test to reflect refactored method name Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/search-replace.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/search-replace.feature b/features/search-replace.feature index eef35187..4bb98ce0 100644 --- a/features/search-replace.feature +++ b/features/search-replace.feature @@ -1132,7 +1132,7 @@ Feature: Do global search/replace When I try `wp search-replace mysqli_result stdClass` Then STDERR should contain: """ - Warning: WP_CLI\SearchReplacer::run_recursively(): Couldn't fetch mysqli_result + Search_Replace_Command::run_search_replace_recursively(): Couldn't fetch mysqli_result """ And STDOUT should contain: """ From c8c1136711c38cc894e473827c0fa23a73492088 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Mon, 2 Feb 2026 22:15:26 -0500 Subject: [PATCH 7/7] Update src/Search_Replace_Command.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Search_Replace_Command.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Search_Replace_Command.php b/src/Search_Replace_Command.php index dc43e3c5..446d1148 100644 --- a/src/Search_Replace_Command.php +++ b/src/Search_Replace_Command.php @@ -1225,7 +1225,21 @@ private function run_search_replace_recursively( $data, $old, $new, $serialised // reporting of notices and warnings as well. $error_reporting = error_reporting(); error_reporting( $error_reporting & ~E_NOTICE & ~E_WARNING ); - $unserialized = is_string( $data ) ? @unserialize( $data ) : false; + $unserialized = false; + if ( is_string( $data ) ) { + // Prevent unsafe object instantiation from attacker-controlled serialized data. + if ( defined( 'PHP_VERSION_ID' ) && PHP_VERSION_ID >= 70000 ) { + $unserialized = @unserialize( + $data, + array( + 'allowed_classes' => false, + ) + ); + } else { + // Fallback for older PHP versions without the allowed_classes option. + $unserialized = @unserialize( $data ); + } + } error_reporting( $error_reporting ); } catch ( \TypeError $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.typeerrorFound