replace pairs for tokenising, longest first. * * @var array */ private $to_tokens = array(); /** * Remaining element budget of the running walk. * * @var int */ private $budget = 0; /** * Regular expression matching any URL variant at a host boundary. * * @var string */ private $pattern = ''; /** * Token => URL pairs for this site. * * @var array */ private $from_tokens = array(); /** * Constructor. * * @param string $home Home URL of the site the data belongs to. * @param string $siteurl Site URL of that site. */ public function __construct( string $home, string $siteurl ) { $home = untrailingslashit( $home ); $siteurl = untrailingslashit( $siteurl ); $pairs = array(); foreach ( array( self::SITEURL => $siteurl, self::HOME => $home ) as $token => $url ) { foreach ( self::variants( $url ) as $variant ) { $pairs[ $variant ] = self::escaped_like( $variant, $token ); } } uksort( $pairs, function ( $a, $b ) { return strlen( $b ) <=> strlen( $a ); } ); $this->to_tokens = $pairs; // The URL must end at a host boundary, so a longer host name that merely starts with it is left alone. $this->pattern = '#(?:' . implode( '|', array_map( function ( $u ) { return preg_quote( $u, '#' ); }, array_keys( $pairs ) ) ) . ')(?![A-Za-z0-9_-]|[.:][A-Za-z0-9])#'; $this->from_tokens = array( self::SITEURL => $siteurl, self::HOME => $home, self::escaped_token( self::SITEURL ) => str_replace( '/', '\\/', $siteurl ), self::escaped_token( self::HOME ) => str_replace( '/', '\\/', $home ), ); } /** * Normalizer for the current site. */ public static function for_this_site(): Normalizer { return new self( home_url(), site_url() ); } /** * Replaces this site's URLs with tokens, recursively. * * @param mixed $value Any value. * @return mixed */ public function tokenize( $value ) { return $this->walk( $value, function ( string $s ) { if ( false === strpos( $s, '//' ) && false === strpos( $s, '\\/\\/' ) ) { return $s; } return (string) preg_replace_callback( $this->pattern, function ( $m ) { return $this->to_tokens[ $m[0] ]; }, $s ); } ); } /** * Replaces tokens with this site's URLs, recursively. * * @param mixed $value Any value. * @return mixed */ public function detokenize( $value ) { return $this->walk( $value, function ( string $s ) { return false === strpos( $s, '{{wsync:' ) ? $s : strtr( $s, $this->from_tokens ); } ); } /** * Applies a string callback to every string inside a value, including nested serialized strings. * * @param mixed $value Any value. * @param callable $callback String callback. * @return mixed */ public function walk( $value, callable $callback, int $depth = 0 ) { // Deeper than any real WordPress data: refuse instead of recursing forever. if ( $depth > 64 ) { throw new \RuntimeException( 'Data is nested too deeply.' ); } if ( 0 === $depth ) { $this->budget = 2000000; } if ( --$this->budget < 0 ) { throw new \RuntimeException( 'Data has too many elements.' ); } if ( is_array( $value ) ) { $out = array(); foreach ( $value as $k => $v ) { $out[ $k ] = $this->walk( $v, $callback, $depth + 1 ); } return $out; } if ( is_object( $value ) ) { // Incomplete classes cannot be rebuilt safely; leave them untouched. if ( $value instanceof \__PHP_Incomplete_Class ) { return $value; } $copy = clone $value; foreach ( get_object_vars( $copy ) as $k => $v ) { $copy->$k = $this->walk( $v, $callback, $depth + 1 ); } return $copy; } if ( is_string( $value ) ) { // Nested serialized data is opened only when it holds no objects or references. if ( is_serialized( $value ) && ! preg_match( '/(?:^|[;{}])[OCrR]:\d+/', $value ) ) { $inner = @unserialize( trim( $value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- nested serialized data from the database, no objects allowed. if ( false !== $inner || 'b:0;' === trim( $value ) ) { return serialize( $this->walk( $inner, $callback, $depth + 1 ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- re-serialize what was serialized. } } return $callback( $value ); } return $value; } /** * Stable hash of a value: key order does not matter, number types do not matter. * * @param mixed $value Any value. */ public static function hash( $value ): string { self::$canonical_budget = 2000000; return md5( (string) wp_json_encode( self::canonical( $value ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); } /** * Remaining element budget of the running hash. * * @var int */ private static $canonical_budget = 0; /** * Sorts keys recursively and casts scalars to strings. * * @param mixed $value Any value. * @return mixed */ /** * Version of the hash rules. Baselines and releases record it; hashes of another version * are never compared. */ const VERSION = 7; /** * Block comments with valid JSON attributes in one canonical spelling. A linear scan, no * regular expression: nothing can fail silently on large content. * * @param string $value Post content. */ private static function canonical_blocks( string $value ): string { $out = ''; $pos = 0; $len = strlen( $value ); while ( $pos < $len ) { $start = strpos( $value, '', $start ); if ( false === $end ) { break; } $out .= substr( $value, $pos, $start - $pos ); $segment = substr( $value, $start, $end + 3 - $start ); $pos = $end + 3; $open = strpos( $segment, '{' ); $close = strrpos( $segment, '}' ); if ( false === $open || false === $close || $close < $open ) { $out .= $segment; continue; } $head = substr( $segment, 0, $open ); $tail = substr( $segment, $close + 1 ); // Objects stay objects ({} and [] differ for the block parser). $attrs = json_decode( substr( $segment, $open, $close - $open + 1 ), false ); $json = is_object( $attrs ) && JSON_ERROR_NONE === json_last_error() ? wp_json_encode( $attrs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : false; // Anything that does not survive a round trip (e.g. 1e400) is hashed as written. if ( ! is_string( $json ) || ! preg_match( '#^$#', $tail ) ) { $out .= $segment; continue; } $out .= $head . "\0" . $json . "\0" . $tail; } return $out . substr( $value, $pos ); } private static function canonical( $value, int $depth = 0 ) { if ( $depth > 64 || --self::$canonical_budget < 0 ) { throw new \RuntimeException( 'Data is nested too deeply or has too many elements.' ); } if ( is_object( $value ) ) { $value = get_object_vars( $value ); } if ( is_array( $value ) ) { $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); $out = array(); foreach ( $value as $k => $v ) { // Masked keys (secrets, installation values) never travel: whether such a key // exists on one side must not make an object look changed. if ( ! $is_list && Exporter::SECRET === $v && Classifier::is_masked_key( (string) $k ) ) { continue; } $out[ $k ] = self::canonical( $v, $depth + 1 ); } if ( ! $is_list ) { ksort( $out, SORT_STRING ); } return $out; } // Block attributes: WordPress and Divi re-serialize them on save and spell characters as // \u0026, \u003c … The meaning is the same, so valid block JSON is hashed in one // canonical spelling. Everything else, including invalid JSON, stays as it is. if ( is_string( $value ) && false !== strpos( $value, '