• Resolved Gabor

    (@nextendweb_gabor)


    Hi! Your DB search/replace turns empty JSON objects into empty JSON arrays, which corrupts data that stores JSON. This breaks sites after a migration/restore.

    The cause is in includes/class-search-replace.php, in recursive_unserialize_replace() (lines 419–428):

    } elseif (is_string($data) && (null !== ($_tmp = json_decode($data, true)))) {
    
        if (is_array($_tmp)) {
            foreach ($_tmp as $key => $value) {
                $_tmp[$key] = $this->recursive_unserialize_replace($from, $to, $value, false, $recursion_level + 1, $visited_data);
            }
    
            $data = json_encode($_tmp);
            unset($_tmp);
        }
    
    }

    json_decode($data, true) decodes objects into associative arrays, so an empty object {} becomes an empty PHP array and json_encode() writes it back as []:

    $data = '{}';                       // value stored in the DB
    $tmp  = json_decode($data, true);   // => []   (empty PHP array)
    $data = json_encode($tmp);          // => "[]"  (empty JSON array — corrupted)

    Removing the true alone isn’t enough, because the code below only handles arrays (is_array + $_tmp[$key]), so objects would be skipped entirely and replacements inside JSON objects would stop working. The complete fix decodes in object mode and handles both objects and arrays:

    } elseif (is_string($data) && (null !== ($_tmp = json_decode($data)))) {
    
        if (is_array($_tmp) || is_object($_tmp)) {
            foreach ($_tmp as $key => $value) {
                $new_value = $this->recursive_unserialize_replace($from, $to, $value, false, $recursion_level + 1, $visited_data);
                if (is_object($_tmp)) {
                    $_tmp->$key = $new_value;
                } else {
                    $_tmp[$key] = $new_value;
                }
            }
    
            $data = json_encode($_tmp);
            unset($_tmp);
        }
    
    }

    This preserves types ({} stays {}, [] stays []) while keeping replacements working for both objects and arrays.

    Minimal repro: put a row containing the JSON string {} in any table, run a search/replace for an unrelated string, and the value becomes [].

Viewing 2 replies - 1 through 2 (of 2 total)
  • Thread Starter Gabor

    (@nextendweb_gabor)

    Following up. Our senior developer debugged this further and forwarded the findings below to me. The empty-object corruption I first reported turns out to be one of several problems in the same eight lines, and the fix I posted earlier needs correcting. All of this was reproduced by running the shipped 1.26.6 method directly (PHP 8.4), and the file was diffed against plugins.svn.wordpress.org/updraftplus/tags/1.26.6/ to confirm it’s released code.

    Scope: this search/replace runs on every column of every row of every prefixed table (migrator-lite.php:1032 + process_row()), excluding only posts.guid and two UpdraftPlus option rows — so it reaches third-party plugin tables too.

    The problems in class-search-replace.php, lines 419–428

    1. JSON objects become JSON arrays. json_decode($data, true) drops the object/array distinction, so json_encode() guesses from the keys on the way back:

    {}                     ->  []
    {"0":"x","1":"y"}      ->  ["x","y"]

    2. Bare JSON scalars skip replacement entirely. The branch is entered for any valid JSON, but only acts if (is_array($_tmp)). A JSON-encoded string (e.g. update_option() storing a URL) isn’t an array, so nothing happens — and the str_replace() in the else is never reached:

    "http://old.example.com"   ->  "http://old.example.com"   (NOT migrated)

    3. Numbers are re-encoded lossily. No JSON_PRESERVE_ZERO_FRACTION / JSON_BIGINT_AS_STRING:

    {"count":1.0}                   ->  {"count":1}
    {"id":12345678901234567890}     ->  {"id":1.2345678901234567e+19}   (unrecoverable)

    4. Rows are rewritten even when nothing matched. There’s no “did anything change” guard. The decode/encode round-trip alters escaping (/\/, unicode) regardless of the search term, process_row() sees a difference at line 310 and writes the row back. So every JSON value in every table is rewritten on every migration — and gets problems 1–3 applied — even when it had nothing to do with the URL:

    (search term appears nowhere in the value)
    {"tags":{},"ratio":2.50}   ->  {"tags":[],"ratio":2.5}

    5. Related — recursion cap. With max_recursion at 20, deep page-builder JSON loses the deep replacement and still gets its outer object flipped to [] — corrupted for a replacement that didn’t even happen. Fixing #4 covers this, since untouched rows are then left alone.

    Correction to my earlier post

    Just changing json_decode($data, true) to json_decode($data) is not enough — please don’t apply that alone. Nested values then become stdClass and recurse into the object branch at line 406, whose property-name filter at line 412 skips numeric keys, so {"0":"http://old.example.com"} would silently stop being replaced. That filter is correct where it is (it guards get_object_vars() output for private/protected props), so the JSON needs its own walker instead. Recommended change (drop-in)

    Your code now — lines 419–428:

    } elseif (is_string($data) && (null !== ($_tmp = json_decode($data, true)))) {
    
    	if (is_array($_tmp)) {
    		foreach ($_tmp as $key => $value) {
    			$_tmp[$key] = $this->recursive_unserialize_replace($from, $to, $value, false, $recursion_level + 1, $visited_data);
    		}
    
    		$data = json_encode($_tmp);
    		unset($_tmp);
    	}
    
    }

    Replace it with:

    } elseif (is_string($data) && '' !== $data && (null !== ($_tmp = json_decode($data)))) {
    
    	if (is_object($_tmp) || is_array($_tmp)) {
    
    		$json_changed = false;
    		$replaced = $this->recursive_json_replace($from, $to, $_tmp, $recursion_level, $visited_data, $json_changed);
    
    		// Only re-encode when a replacement actually happened; otherwise leave the
    		// original string byte-for-byte intact. A flag is used rather than comparing
    		// $replaced against $_tmp: loose == treats numeric strings as numbers
    		// ("0" == "0.0"), and strict === on the rebuilt object is never true.
    		if ($json_changed) {
    			$encoded = json_encode($replaced, JSON_PRESERVE_ZERO_FRACTION);
    			if (false !== $encoded) $data = $encoded;
    		}
    
    		unset($_tmp);
    	} else {
    		// Valid JSON, but a scalar (number, boolean or quoted string) — still needs
    		// the plain string replace that the final else branch performs.
    		$data = $case_insensitive ? str_ireplace($from, $to, $data) : str_replace($from, $to, $data);
    	}
    
    }

    And add this new private method (walks the decoded JSON, preserving object vs. array, without the PHP-property-name filter):

    private function recursive_json_replace($from, $to, $data, $recursion_level, $visited_data, &$changed) {
    
    	if (0 !== $this->max_recursion && $recursion_level >= $this->max_recursion) return $data;
    
    	if (is_object($data)) {
    		$new = new stdClass;
    		foreach (get_object_vars($data) as $key => $value) {
    			$new->$key = $this->recursive_json_replace($from, $to, $value, $recursion_level + 1, $visited_data, $changed);
    		}
    		return $new;
    	}
    
    	if (is_array($data)) {
    		$new = array();
    		foreach ($data as $key => $value) {
    			$new[$key] = $this->recursive_json_replace($from, $to, $value, $recursion_level + 1, $visited_data, $changed);
    		}
    		return $new;
    	}
    
    	// Strings go back through the main engine so nested serialised/JSON data is still
    	// handled. Ints, floats, bools and null are returned untouched (no precision loss).
    	if (is_string($data)) {
    		$new = $this->recursive_unserialize_replace($from, $to, $data, false, $recursion_level, $visited_data);
    		if ($new !== $data) $changed = true;
    		return $new;
    	}
    
    	return $data;
    }

    Two things in there that look simplifiable but aren’t: the change detection must stay a flag (a loose != would drop "0""0.0"; a strict !== on the rebuilt object always reports a change), and the helper is entered at $recursion_level (not +1) so the depth budget matches current behavior. Verified after the patch

    {}                                              ->  {}                              (preserved)
    "http://old.example.com"                        ->  "http://new.example.com"        (now migrated)
    {"count":1.0}                                   ->  {"count":1.0}                   (preserved)
    {"a":"café","b":"x/y"}      (no match)          ->  {"a":"café","b":"x/y"}          (byte-identical, not rewritten)
    {"settings":{},"url":"...old.example.com"}      ->  {"settings":{},"url":"...new.example.com"}

    The cases the branch exists for (URLs nested in JSON objects/arrays, URLs inside serialized data) still replace correctly, and nesting-depth behavior is unchanged.

    One limitation: integers above PHP_INT_MAX still lose precision in rows that genuinely did contain the search term, since those rows do get re-encoded — fixing that fully needs JSON_BIGINT_AS_STRING and felt out of proportion for a minimal patch. The patch’s win is that this no longer happens to every JSON row regardless of relevance.

    Plugin Contributor bcrodua

    (@bcrodua)

    Hi,

    Thank you for providing the information.

    I’ve forwarded your report to one of our developers so they can take a closer look at the behavior you’re seeing with the Search and Replace feature.

    We’ll get back to you once we’ve had a chance to review it further. Thank you for your patience in the meantime.

    Kind regards,
    Bryle

Viewing 2 replies - 1 through 2 (of 2 total)

You must be logged in to reply to this topic.