Generated and user pairs merged (user wins on a clash). */ private array $pairs = []; /** One-pass alternation: generated olds carry a host/path boundary lookahead, user olds are plain substrings. */ private string $regex = ''; /** * @param array $pairs Generated URL/path pairs (old => new), matched with a boundary. * @param array $user_pairs User find/replace pairs, plain substring semantics; an empty new removes text. */ public function __construct( array $pairs, array $user_pairs = [] ) { $entries = []; // old => boundary? foreach ( $pairs as $old => $new ) { $old = (string) $old; if ( '' !== $old && $old !== (string) $new ) { $this->pairs[ $old ] = (string) $new; $entries[ $old ] = true; } } foreach ( $user_pairs as $old => $new ) { $old = (string) $old; if ( '' !== $old && $old !== (string) $new ) { $this->pairs[ $old ] = (string) $new; $entries[ $old ] = false; } } if ( $entries ) { $olds = array_keys( $entries ); usort( $olds, static fn( $x, $y ) => strlen( $y ) <=> strlen( $x ) ); $alts = []; foreach ( $olds as $o ) { $q = preg_quote( (string) $o, '/' ); $alts[] = $entries[ $o ] ? '(?:' . $q . ')(?![A-Za-z0-9_\\-]|\\.[A-Za-z0-9])' : $q; } $this->regex = '/' . implode( '|', $alts ) . '/'; } } /** * Builds the generated old => new map from source and target site facts (home and site URL in http, https and * protocol-relative form, JSON-escaped and urlencoded variants, plus the uploads and content paths) and returns it * beside the user find/replace pairs. * * @param array $from { home, siteurl, uploads_path, abspath_content } of the source. * @param array $to Same keys for the target. * @param array $user_pairs Extra literal old => new pairs (plain substring semantics, empty new allowed). * @return array{generated:array,user:array} */ public static function build_pairs( array $from, array $to, array $user_pairs = [] ): array { $pairs = []; $add = static function ( string $old, string $new ) use ( &$pairs ): void { if ( '' === $old || $old === $new ) { return; } $pairs[ $old ] = $new; $pairs[ str_replace( '/', '\/', $old ) ] = str_replace( '/', '\/', $new ); $pairs[ rawurlencode( $old ) ] = rawurlencode( $new ); }; foreach ( [ 'home', 'siteurl' ] as $key ) { $old = self::split_url( (string) ( $from[ $key ] ?? '' ) ); $new = self::split_url( (string) ( $to[ $key ] ?? '' ) ); if ( null === $old || null === $new ) { continue; } $differs = $old['scheme'] !== $new['scheme']; foreach ( [ 'http', 'https' ] as $scheme ) { $target_scheme = $differs ? $new['scheme'] : $scheme; $add( $scheme . '://' . $old['rest'], $target_scheme . '://' . $new['rest'] ); } $add( '//' . $old['rest'], '//' . $new['rest'] ); } foreach ( [ 'uploads_path', 'abspath_content' ] as $key ) { $old = rtrim( (string) ( $from[ $key ] ?? '' ), '/\\' ); $new = rtrim( (string) ( $to[ $key ] ?? '' ), '/\\' ); if ( '' !== $old ) { $pairs[ $old ] = $new; $escaped_old = str_replace( '/', '\/', $old ); if ( $escaped_old !== $old ) { $pairs[ $escaped_old ] = str_replace( '/', '\/', $new ); } } } foreach ( $pairs as $old => $new ) { if ( (string) $old === $new || '' === (string) $old || '' === $new ) { unset( $pairs[ $old ] ); } } $user = []; foreach ( $user_pairs as $old => $new ) { if ( '' !== (string) $old ) { $user[ (string) $old ] = (string) $new; } } return [ 'generated' => $pairs, 'user' => $user ]; } /** @return array{scheme:string,rest:string}|null host plus path with no trailing slash. */ private static function split_url( string $url ): ?array { $url = trim( $url ); if ( ! preg_match( '#^(?:(https?):)?//(.+?)/*$#i', $url, $m ) ) { return null; } return [ 'scheme' => '' === $m[1] ? 'http' : strtolower( $m[1] ), 'rest' => $m[2] ]; } /** Replaces inside strings, arrays and nested arrays; every other type is returned unchanged. */ public function replace( $value ) { if ( is_string( $value ) ) { return $this->replace_cell( $value ); } if ( is_array( $value ) ) { $out = []; foreach ( $value as $k => $v ) { $out[ is_string( $k ) ? $this->replace_cell( $k ) : $k ] = $this->replace( $v ); } return $out; } return $value; } /** Replaces inside one database cell string. */ public function replace_cell( string $value ): string { return $this->replace_string( $value, 0 ); } private function plain( string $s ): string { if ( '' === $this->regex ) { return $s; } $out = @preg_replace_callback( $this->regex, fn( array $m ): string => $this->pairs[ $m[0] ] ?? $m[0], $s ); return is_string( $out ) ? $out : $s; } private function replace_string( string $v, int $depth ): string { if ( '' === $v || ! $this->pairs ) { return $v; } if ( $this->plain( $v ) === $v ) { return $v; // Nothing to replace: keep the exact original bytes. } if ( $depth >= self::MAX_DEPTH ) { return $this->plain( $v ); } if ( $this->looks_serialized( $v ) ) { $out = $this->replace_serialized( $v, $depth ); if ( null !== $out ) { return $out; } return $this->replace_serialized_tokens( $v, $depth ); } $trim = ltrim( $v ); if ( '' !== $trim && ( '{' === $trim[0] || '[' === $trim[0] ) ) { $out = $this->replace_json( $v, $depth ); if ( null !== $out ) { return $out; } } return $this->plain( $v ); } private function looks_serialized( string $v ): bool { if ( function_exists( 'is_serialized' ) ) { return is_serialized( $v ); } return 1 === preg_match( '/^(?:[aOsCiNbd][:;]|N;)/', trim( $v ) ); } /** Safe unserialize, walk, re-serialize. Null means "use the token fallback". */ private function replace_serialized( string $v, int $depth ): ?string { if ( preg_match( '/[;{}][Rr]:\d+;/', $v ) ) { return null; // PHP references would be flattened by a walk. } $data = @unserialize( $v, [ 'allowed_classes' => false ] ); if ( false === $data && 'b:0;' !== trim( $v ) ) { return null; } if ( $this->has_incomplete( $data ) ) { return null; } $walked = $this->walk( $data, $depth + 1 ); try { return serialize( $walked ); } catch ( Throwable $e ) { return null; } } private function has_incomplete( $data ): bool { if ( is_object( $data ) ) { return true; // With allowed_classes=false every object is __PHP_Incomplete_Class. } if ( is_array( $data ) ) { foreach ( $data as $item ) { if ( $this->has_incomplete( $item ) ) { return true; } } } return false; } private function walk( $data, int $depth ) { if ( is_string( $data ) ) { return $this->replace_string( $data, $depth ); } if ( is_array( $data ) ) { $out = []; foreach ( $data as $k => $item ) { $out[ is_string( $k ) ? $this->plain( $k ) : $k ] = $this->walk( $item, $depth ); } return $out; } if ( $data instanceof stdClass ) { foreach ( get_object_vars( $data ) as $k => $item ) { unset( $data->$k ); $data->{ is_string( $k ) ? $this->plain( $k ) : $k } = $this->walk( $item, $depth ); } } return $data; } /** * Fallback: replaces inside each s::"..."; token and rewrites the byte length. A token whose declared length * does not land on the closing quote and semicolon is closed at the next token boundary instead. */ private function replace_serialized_tokens( string $v, int $depth ): string { $out = ''; $offset = 0; $len = strlen( $v ); $boundary = '(?=s:\d+:"|i:-?\d+;|[bd]:[0-9.eE+-]+;|N;|a:\d+:\{|O:\d+:"|\}|\z)'; while ( $offset < $len && preg_match( '/(?replace_string( $body, $depth + 1 ); $out .= substr( $v, $offset, $token_start - $offset ) . 's:' . strlen( $new_body ) . ':"' . $new_body . '";'; $offset = $next; } return $out . substr( $v, $offset ); } /** Decode, walk, encode in the original style. Null means "not valid JSON". */ private function replace_json( string $v, int $depth ): ?string { if ( preg_match( '/(?walk_json( $data, $depth + 1 ), $flags ); return false === $out ? null : $out; } private function walk_json( $data, int $depth ) { if ( is_string( $data ) ) { return $this->replace_string( $data, $depth ); } if ( is_array( $data ) ) { foreach ( $data as $k => $item ) { $data[ $k ] = $this->walk_json( $item, $depth ); } return $data; } if ( is_object( $data ) ) { $out = new stdClass(); foreach ( get_object_vars( $data ) as $k => $item ) { $out->{ $this->plain( (string) $k ) } = $this->walk_json( $item, $depth ); } return $out; } return $data; } }