$rules Each: {find, replace, case_sensitive}. * @param array $stats Out: replacement counters. * @return mixed */ public static function replace_value( $value, array $rules, array &$stats = array() ) { if ( is_string( $value ) && is_serialized( $value ) ) { $pos = 0; $rewritten = self::parse( $value, $pos, $rules, $stats ); // A clean parse must consume the entire string; otherwise leave the // original untouched (never corrupt data we couldn't fully understand). if ( null !== $rewritten && strlen( $value ) === $pos ) { return $rewritten; } $stats['skipped_serialized'] = isset( $stats['skipped_serialized'] ) ? $stats['skipped_serialized'] + 1 : 1; return $value; } if ( is_string( $value ) ) { return self::replace_string( $value, $rules, $stats ); } return $value; } /** * Recursive-descent parse of one serialized value at $pos, returning the * rewritten serialized substring and advancing $pos past it. Returns null on * any malformed input so the caller can fall back to the original. * * @param string $s Full serialized string being parsed. * @param int $pos Current byte offset; advanced past the parsed value. * @param array $rules Each: {find, replace, case_sensitive}. * @param array $stats Out: replacement counters. * @return string|null Rewritten serialized value, or null on malformed input. */ private static function parse( string $s, int &$pos, array $rules, array &$stats ): ?string { $type = $s[ $pos ] ?? ''; switch ( $type ) { case 'N': // Null: `N;`. if ( 'N;' === substr( $s, $pos, 2 ) ) { $pos += 2; return 'N;'; } return null; case 'b': // Bool: `b:0;` / `b:1;`. if ( preg_match( '/b:[01];/A', $s, $m, 0, $pos ) ) { $pos += strlen( $m[0] ); return $m[0]; } return null; case 'i': // Int: `i:123;`. if ( preg_match( '/i:-?\d+;/A', $s, $m, 0, $pos ) ) { $pos += strlen( $m[0] ); return $m[0]; } return null; case 'd': // Double: `d:1.5;` / `d:INF;` / `d:NAN;`. if ( preg_match( '/d:[^;]*;/A', $s, $m, 0, $pos ) ) { $pos += strlen( $m[0] ); return $m[0]; } return null; case 's': // String: `s:LEN:"DATA";`. return self::parse_string( $s, $pos, $rules, $stats ); case 'a': // Array: `a:COUNT:{…}` holding COUNT key/value pairs. return self::parse_collection( $s, $pos, $rules, $stats, '/a:(\d+):\{/A', 'a:' ); case 'O': // Object: `O:LEN:"CLASS":COUNT:{ k v … }`. return self::parse_object( $s, $pos, $rules, $stats ); default: return null; } } /** * Parse `s:LEN:"DATA";`, replace inside DATA, and re-emit with the corrected * byte length. DATA is read by LEN (not by scanning for `";`) so binary/quoted * content is safe. * * @param string $s Full serialized string being parsed. * @param int $pos Current byte offset; advanced past the string token. * @param array $rules Each: {find, replace, case_sensitive}. * @param array $stats Out: replacement counters. * @return string|null Re-emitted string token, or null on malformed input. */ private static function parse_string( string $s, int &$pos, array $rules, array &$stats ): ?string { if ( ! preg_match( '/s:(\d+):"/A', $s, $m, 0, $pos ) ) { return null; } $len = (int) $m[1]; $data_start = $pos + strlen( $m[0] ); if ( $data_start + $len + 2 > strlen( $s ) ) { return null; } $data = substr( $s, $data_start, $len ); if ( '";' !== substr( $s, $data_start + $len, 2 ) ) { return null; } $new = self::replace_string( $data, $rules, $stats ); $pos = $data_start + $len + 2; return 's:' . strlen( $new ) . ':"' . $new . '";'; } /** * Parse an array (`a:COUNT:{…}`) — each element is a key value pair, both of * which are themselves serialized values. * * @param string $s Full serialized string being parsed. * @param int $pos Current byte offset; advanced past the collection. * @param array $rules Each: {find, replace, case_sensitive}. * @param array $stats Out: replacement counters. * @param string $header_re Anchored regex matching the collection header and capturing COUNT. * @param string $prefix Header prefix to re-emit (e.g. `a:`). * @return string|null Rewritten collection, or null on malformed input. */ private static function parse_collection( string $s, int &$pos, array $rules, array &$stats, string $header_re, string $prefix ): ?string { if ( ! preg_match( $header_re, $s, $m, 0, $pos ) ) { return null; } $count = (int) $m[1]; $pos += strlen( $m[0] ); $out = $prefix . $count . ':{'; for ( $i = 0; $i < $count; $i++ ) { $key = self::parse( $s, $pos, $rules, $stats ); if ( null === $key ) { return null; } $val = self::parse( $s, $pos, $rules, $stats ); if ( null === $val ) { return null; } $out .= $key . $val; } if ( '}' !== ( $s[ $pos ] ?? '' ) ) { return null; } ++$pos; return $out . '}'; } /** * Parse `O:LEN:"CLASS":COUNT:{…}` WITHOUT instantiating the class. The class * name is preserved verbatim; property name/value pairs are parsed like an * array (private/protected keys carry NUL bytes and survive via byte lengths). * * @param string $s Full serialized string being parsed. * @param int $pos Current byte offset; advanced past the object. * @param array $rules Each: {find, replace, case_sensitive}. * @param array $stats Out: replacement counters. * @return string|null Rewritten object, or null on malformed input. */ private static function parse_object( string $s, int &$pos, array $rules, array &$stats ): ?string { if ( ! preg_match( '/O:(\d+):"/A', $s, $m, 0, $pos ) ) { return null; } $clen = (int) $m[1]; $class_start = $pos + strlen( $m[0] ); if ( $class_start + $clen > strlen( $s ) ) { return null; } $class = substr( $s, $class_start, $clen ); $after = $class_start + $clen; if ( ! preg_match( '/":(\d+):\{/A', $s, $m2, 0, $after ) ) { return null; } $count = (int) $m2[1]; $pos = $after + strlen( $m2[0] ); $out = 'O:' . $clen . ':"' . $class . '":' . $count . ':{'; for ( $i = 0; $i < $count; $i++ ) { $key = self::parse( $s, $pos, $rules, $stats ); if ( null === $key ) { return null; } $val = self::parse( $s, $pos, $rules, $stats ); if ( null === $val ) { return null; } $out .= $key . $val; } if ( '}' !== ( $s[ $pos ] ?? '' ) ) { return null; } ++$pos; return $out . '}'; } /** * Replace text against rules (leaf strings) in a SINGLE simultaneous pass. * * Sequential str_replace (one rule after another, each on the prior output) is * unsafe when a rule's REPLACEMENT contains another rule's FIND — e.g. appending a * path segment to a URL (`…/web` → `…/web/abc`): a later/other variant whose find is * still `…/web` re-matches inside the new value and appends again, corrupting it. * Instead, we scan left-to-right once: at each position take the LONGEST applicable * rule (most-specific wins), emit its replacement, and advance past the matched * SOURCE — so replacements are never re-scanned and overlapping rules can't compound. * Per-rule case sensitivity and the replacement count are preserved. * * @param string $subject Plain (leaf) string to rewrite. * @param array $rules Each: {find, replace, case_sensitive}. * @param array $stats Out: replacement counters. * @return string */ private static function replace_string( string $subject, array $rules, array &$stats ): string { // Normalize once: keep usable rules, longest-find-first (most specific wins). $usable = array(); foreach ( $rules as $rule ) { $find = (string) ( $rule['find'] ?? '' ); if ( '' === $find ) { continue; } $usable[] = array( 'find' => $find, 'first' => $find[0], 'len' => strlen( $find ), 'replace' => (string) ( $rule['replace'] ?? '' ), 'ci' => empty( $rule['case_sensitive'] ), ); } if ( empty( $usable ) ) { return $subject; } usort( $usable, static function ( $a, $b ) { return $b['len'] <=> $a['len']; } ); $len = strlen( $subject ); $out = ''; $i = 0; while ( $i < $len ) { $ch = $subject[ $i ]; $matched = null; foreach ( $usable as $r ) { if ( $i + $r['len'] > $len ) { continue; } // Cheap first-byte gate before the substring compare. if ( $r['ci'] ? ( 0 !== strcasecmp( $ch, $r['first'] ) ) : ( $ch !== $r['first'] ) ) { continue; } $chunk = substr( $subject, $i, $r['len'] ); $hit = $r['ci'] ? ( 0 === strcasecmp( $chunk, $r['find'] ) ) : ( $chunk === $r['find'] ); if ( $hit ) { $matched = $r; break; } } if ( null !== $matched ) { $out .= $matched['replace']; // Emitted as-is; never re-scanned. $i += $matched['len']; // Skip the matched source. $stats['replacements'] = ( $stats['replacements'] ?? 0 ) + 1; } else { $out .= $ch; ++$i; } } return $out; } }