replace map. * * @var array */ private $map = array(); /** * Regex for all pairs. * * @var string */ private $regex = ''; /** * Regex for pairs whose search and replacement have the same length. * * @var string */ private $regex_equal = ''; /** * Cheap substrings: a value that contains none of these can't match. * * @var string[] */ private $needles = array(); /** * Counters for the restore report. * * @var array */ public $stats = array( 'changed' => 0, 'unparseable' => 0, 'custom_objects' => 0, 'regex_failed' => 0, ); /** * Creates a replacer. * * @param array $pairs Search => replace (variants already expanded). * @param string[] $needles Pre-check substrings (see Replacement_Plan). */ public function __construct( array $pairs, array $needles ) { $pairs = array_filter( $pairs, static function ( $replace, $search ) { return '' !== (string) $search && (string) $search !== (string) $replace; }, ARRAY_FILTER_USE_BOTH ); uksort( $pairs, static function ( $a, $b ) { return strlen( $b ) <=> strlen( $a ); // Longest first. } ); $this->map = $pairs; $this->needles = array_values( array_filter( array_unique( $needles ), 'strlen' ) ); $this->regex = self::build_regex( array_keys( $pairs ) ); $equal = array_filter( array_keys( $pairs ), function ( $search ) { return strlen( $search ) === strlen( $this->map[ $search ] ); } ); $this->regex_equal = $equal ? self::build_regex( $equal ) : ''; } /** * Alternation regex with the boundary rule. * * @param string[] $searches Search strings (longest first). */ private static function build_regex( array $searches ) { if ( ! $searches ) { return ''; } $parts = array(); foreach ( $searches as $search ) { $parts[] = preg_quote( $search, '/' ); } return '/(?:' . implode( '|', $parts ) . ')(?![A-Za-z0-9_.\-])/'; } /** * Whether there is anything to replace at all. */ public function active() { return '' !== $this->regex; } /** * Whether a value could contain a match (fast pre-check). * * @param string $value Value. */ public function may_match( $value ) { foreach ( $this->needles as $needle ) { if ( false !== strpos( $value, $needle ) ) { return true; } } return false; } /** * Replaces in a raw database value. * * @param string $value Value. */ public function replace( $value ) { if ( ! $this->active() || ! is_string( $value ) || ! $this->may_match( $value ) ) { return $value; } if ( self::looks_serialized( $value ) ) { $pos = 0; $out = ''; if ( $this->parse( $value, $pos, $out, 0 ) && strlen( $value ) === $pos ) { if ( $out !== $value ) { ++$this->stats['changed']; } return $out; } // Looks serialized but doesn't parse (already damaged): only length-preserving changes. ++$this->stats['unparseable']; $new = '' === $this->regex_equal ? $value : $this->plain( $value, $this->regex_equal ); } else { $new = $this->plain( $value, $this->regex ); } if ( $new !== $value ) { ++$this->stats['changed']; } return $new; } /** * Whether a value has the shape of PHP-serialized data. * * @param string $v Value. */ public static function looks_serialized( $v ) { $len = strlen( $v ); if ( $len < 2 ) { return false; } if ( 'N;' === $v ) { return true; } $end = $v[ $len - 1 ]; if ( ';' !== $end && '}' !== $end ) { return false; } return (bool) preg_match( '/^(?:b:[01];|i:[+-]?\d+;|d:[^;]+;|s:\d+:"|S:\d+:"|a:\d+:\{|O:\d+:"|C:\d+:"|E:\d+:")/', $v ); } /** * Plain replacement with the boundary rule. * * @param string $value Value. * @param string $regex Regex. */ private function plain( $value, $regex ) { $map = $this->map; $out = preg_replace_callback( $regex, static function ( $m ) use ( $map ) { return $map[ $m[0] ]; }, $value ); if ( null === $out ) { ++$this->stats['regex_failed']; // Left unchanged; reported after the restore. return $value; } return $out; } /** * Replacement inside a serialized string's contents (which may be serialized again). * * @param string $inner Contents. * @param int $depth Depth. */ private function nested( $inner, $depth ) { if ( ! $this->may_match( $inner ) ) { return $inner; } if ( self::looks_serialized( $inner ) ) { $pos = 0; $out = ''; if ( $this->parse( $inner, $pos, $out, $depth + 1 ) && strlen( $inner ) === $pos ) { return $out; } } // A plain string (or damaged inner data): its length is recomputed by the caller. return $this->plain( $inner, $this->regex ); } /** * Parses one serialized value at $pos, appending the rewritten form to $out. * * @param string $s Input. * @param int $pos Position (advanced). * @param string $out Output (appended). * @param int $depth Nesting depth. * @return bool False when the input is not valid serialized data. */ private function parse( $s, &$pos, &$out, $depth ) { if ( $depth > self::MAX_DEPTH || ! isset( $s[ $pos ] ) ) { return false; } switch ( $s[ $pos ] ) { case 'N': if ( 'N;' !== substr( $s, $pos, 2 ) ) { return false; } $out .= 'N;'; $pos += 2; return true; case 'b': case 'i': case 'd': case 'r': case 'R': $re = array( 'b' => '/\Gb:[01];/', 'i' => '/\Gi:[+-]?\d+;/', 'd' => '/\Gd:(?:-?INF|NAN|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?);/', 'r' => '/\Gr:\d+;/', 'R' => '/\GR:\d+;/', ); if ( ! preg_match( $re[ $s[ $pos ] ], $s, $m, 0, $pos ) ) { return false; } $out .= $m[0]; $pos += strlen( $m[0] ); return true; case 's': $inner = $this->read_string( $s, $pos ); if ( null === $inner ) { return false; } $new = $this->nested( $inner, $depth ); $out .= 's:' . strlen( $new ) . ':"' . $new . '";'; return true; case 'a': if ( ! preg_match( '/\Ga:(\d+):\{/', $s, $m, 0, $pos ) ) { return false; } $out .= $m[0]; $pos += strlen( $m[0] ); return $this->parse_members( $s, $pos, $out, $depth, (int) $m[1] ); case 'O': if ( ! preg_match( '/\GO:(\d+):"/', $s, $m, 0, $pos ) ) { return false; } $class_start = $pos + strlen( $m[0] ); $class_len = (int) $m[1]; if ( '":' !== substr( $s, $class_start + $class_len, 2 ) ) { return false; } if ( ! preg_match( '/\G(\d+):\{/', $s, $m2, 0, $class_start + $class_len + 2 ) ) { return false; } $head = substr( $s, $pos, $class_start + $class_len + 2 - $pos ) . $m2[0]; $out .= $head; $pos += strlen( $head ); return $this->parse_members( $s, $pos, $out, $depth, (int) $m2[1] ); case 'C': // Custom Serializable payload: opaque, copied as-is. if ( ! preg_match( '/\GC:(\d+):"/', $s, $m, 0, $pos ) ) { return false; } $class_start = $pos + strlen( $m[0] ); $class_len = (int) $m[1]; if ( '":' !== substr( $s, $class_start + $class_len, 2 ) || ! preg_match( '/\G(\d+):\{/', $s, $m2, 0, $class_start + $class_len + 2 ) ) { return false; } $data_start = $class_start + $class_len + 2 + strlen( $m2[0] ); $data_len = (int) $m2[1]; if ( '}' !== substr( $s, $data_start + $data_len, 1 ) ) { return false; } $raw = substr( $s, $pos, $data_start + $data_len + 1 - $pos ); if ( $this->may_match( substr( $s, $data_start, $data_len ) ) ) { ++$this->stats['custom_objects']; } $out .= $raw; $pos += strlen( $raw ); return true; case 'E': if ( ! preg_match( '/\GE:(\d+):"/', $s, $m, 0, $pos ) ) { return false; } $start = $pos + strlen( $m[0] ); $len = (int) $m[1]; if ( '";' !== substr( $s, $start + $len, 2 ) ) { return false; } $out .= substr( $s, $pos, $start + $len + 2 - $pos ); $pos = $start + $len + 2; return true; } return false; } /** * Parses n key/value pairs and the closing brace. Keys are copied untouched. * * @param string $s Input. * @param int $pos Position. * @param string $out Output. * @param int $depth Depth. * @param int $n Pair count. */ private function parse_members( $s, &$pos, &$out, $depth, $n ) { for ( $i = 0; $i < $n; $i++ ) { if ( ! isset( $s[ $pos ] ) ) { return false; } if ( 'i' === $s[ $pos ] ) { if ( ! preg_match( '/\Gi:[+-]?\d+;/', $s, $m, 0, $pos ) ) { return false; } $out .= $m[0]; $pos += strlen( $m[0] ); } elseif ( 's' === $s[ $pos ] ) { $start = $pos; if ( null === $this->read_string( $s, $pos ) ) { return false; } $out .= substr( $s, $start, $pos - $start ); } else { return false; } if ( ! $this->parse( $s, $pos, $out, $depth + 1 ) ) { return false; } } if ( '}' !== ( $s[ $pos ] ?? '' ) ) { return false; } $out .= '}'; ++$pos; return true; } /** * Reads an s:N:"..."; token at $pos and returns its contents (advancing $pos), or null. * * @param string $s Input. * @param int $pos Position. * @return string|null */ private function read_string( $s, &$pos ) { if ( ! preg_match( '/\Gs:(\d+):"/', $s, $m, 0, $pos ) ) { return null; } $start = $pos + strlen( $m[0] ); $len = (int) $m[1]; if ( $start + $len + 2 > strlen( $s ) || '";' !== substr( $s, $start + $len, 2 ) ) { return null; } $pos = $start + $len + 2; return substr( $s, $start, $len ); } }