skip_columns = array_values( array_unique( array_merge( $this->skip_columns, $extra ) ) ); } $this->skip_columns = array_map( 'strtolower', $this->skip_columns ); } /** * Apply the rule set to table rows (as returned by wpdb->get_results(ARRAY_A)). * * @param array> $rows Table rows. * @param string $table Table the rows came from, for per-rule scoping. * @param string $prefix Site prefix, so a bare scope entry resolves. * * @return array> Modified rows. */ public function process_rows( array $rows, string $table = '', string $prefix = '' ): array { if ( $this->rule_set->is_empty() ) { return $rows; } // Resolve the rules, their passes and their probe fragments once per // chunk, not once per value: every row here is from the same table. $rules = $this->rule_set->rules_for_table( $table, $prefix ); if ( [] === $rules ) { return $rows; } $batches = self::batches( $rules ); $probes = self::probes_for( $batches ); foreach ( $rows as &$row ) { foreach ( $row as $col => &$value ) { if ( ! is_string( $value ) || '' === $value ) { continue; } if ( in_array( strtolower( $col ), $this->skip_columns, true ) ) { continue; } $value = self::apply_batches( $value, $batches, $probes ); } unset( $value ); } unset( $row ); return $rows; } // ------------------------------------------------------------------------- // Private // ------------------------------------------------------------------------- /** * Apply the resolved passes to one value. * * Operator rules run one at a time because order matters when they overlap. * Generated URL rules run as one batch: they are the same address spelled several * ways, and run in turn they would rewrite each other's output (`example.com` to * `example.com/blog` would become `/blog/blog`). * * @param array $batches Resolved passes. * @param array $probes Probe fragment and case mode per needle. */ private static function apply_batches( string $value, array $batches, array $probes ): string { // Fast-path skip. A value with no backslash has no escaping, so serialize() and // JSON store it verbatim and a slash/quote-free needle fragment appears whenever // the needle does. A backslash means possible escaping where a needle can hide // from a raw scan, so those values are always processed in full. if ( false === strpos( $value, '\\' ) && ! self::value_may_match( $value, $probes ) ) { return $value; } $working = $value; if ( SerializedDataHandler::is_serialized( $value ) || SerializedDataHandler::looks_like_json( $value ) ) { foreach ( $batches as [ $find, $replace, $case_sensitive ] ) { // Structured payloads are decoded, replaced inside string values and // re-encoded, so length prefixes are rebuilt in either case mode. $working = SerializedDataHandler::replace( $working, $find, $replace, $case_sensitive ); } return $working; } foreach ( $batches as [ $find, $replace, $case_sensitive ] ) { $working = SerializedDataHandler::replace_plain( $working, $find, $replace, $case_sensitive ); } return $working; } /** * Probe fragments for every needle in the resolved passes. * * @param array $batches Resolved passes. * * @return array [probe fragment, case sensitive] per needle. */ private static function probes_for( array $batches ): array { $probes = []; foreach ( $batches as [ $finds, $replace, $case_sensitive ] ) { foreach ( $finds as $find ) { $probes[] = [ self::probe_fragment( (string) $find ), (bool) $case_sensitive ]; } } return $probes; } /** * Whether any needle could be present, judged on its probe fragment. An empty * probe cannot be pre-filtered, so the value is processed to stay correct. * * @param array $probes Probe fragment and case mode per needle. */ private static function value_may_match( string $value, array $probes ): bool { foreach ( $probes as [ $probe, $case_sensitive ] ) { if ( '' === $probe ) { return true; } $present = $case_sensitive ? false !== strpos( $value, $probe ) : false !== stripos( $value, $probe ); if ( $present ) { return true; } } return false; } /** * The longest run of a needle that survives every encoding verbatim (free of the * slash, quote and backslash JSON escapes). Its absence proves the needle is absent. */ private static function probe_fragment( string $needle ): string { // The longest run with none of the three bytes JSON escapes: slash, // double quote, backslash. A plain scan, no regex to mis-escape. $delimiters = "/\"\\"; $longest = ''; $start = 0; $len = strlen( $needle ); for ( $i = 0; $i <= $len; $i++ ) { if ( $i === $len || false !== strpos( $delimiters, $needle[ $i ] ) ) { $run = substr( $needle, $start, $i - $start ); if ( strlen( $run ) > strlen( $longest ) ) { $longest = $run; } $start = $i + 1; } } return $longest; } /** * Group rules into the passes they run in. Each operator rule is its own pass; * consecutive generated rules sharing a case mode collapse into one. * * @param Rule[] $rules Rules in declaration order. * * @return array [finds, replacements, case sensitive]. */ private static function batches( array $rules ): array { $batches = []; $open = false; foreach ( $rules as $rule ) { if ( '' === $rule->find ) { continue; } $last = count( $batches ) - 1; if ( $open && $rule->generated && $batches[ $last ][2] === $rule->case_sensitive ) { $batches[ $last ][0][] = $rule->find; $batches[ $last ][1][] = $rule->replace; continue; } $batches[] = [ [ $rule->find ], [ $rule->replace ], $rule->case_sensitive ]; $open = $rule->generated; } return $batches; } }