implode( '|', $parts ), 'skipped' => $built['skipped'], ); } /** * Build the user-agent alternation for a server rule. * * PHP matches user agents with a case-insensitive SUBSTRING test (see * Cache::should_cache()), not a glob — so each entry becomes a plain * escaped literal and the rule is applied case-insensitively by the * caller. An empty list yields an empty regex, and callers must then * omit the rule entirely rather than emit one that matches everything. * * @param string[] $patterns Raw `bypass_user_agents` setting. * @return array{regex:string,skipped:int} */ public static function user_agent_rule( array $patterns ): array { $parts = array(); $skipped = 0; foreach ( $patterns as $pattern ) { $pattern = trim( (string) $pattern ); if ( '' === $pattern ) { continue; } // Raw regex is PHP-side only — see the class docblock. if ( '~' === $pattern[0] ) { ++$skipped; continue; } if ( strlen( $pattern ) > self::MAX_PATTERN_LEN ) { ++$skipped; continue; } // Anything that could break out of the emitted config line is // unrepresentable — see SAFE_UA_CHARS. if ( ! preg_match( self::SAFE_UA_CHARS, $pattern ) ) { ++$skipped; continue; } if ( count( $parts ) >= self::MAX_PATTERNS ) { ++$skipped; continue; } // UA matching is substring in PHP, so every character is a // literal here — including `*`, which PHP does NOT treat as a // wildcard on this setting. $parts[] = preg_quote( $pattern, '' ); } return array( 'regex' => implode( '|', array_values( array_unique( $parts ) ) ), 'skipped' => $skipped, ); } /** * Translate a list of glob/substring patterns into escaped regex * alternation parts, mirroring Glob_Matcher's semantics as closely as * a server config can. * * Glob_Matcher rules we reproduce: * - a bare substring is "contains" → emitted as an escaped literal * - `*` is any run of characters → emitted as `.*` * - `?` is exactly one character → emitted as `.` * - `\*` is a literal asterisk → escaped literal * * Rules we deliberately do NOT reproduce, counting them as skipped: * - `~raw regex` (dialect mismatch, unvalidated user input) * - `[abc]` character classes (rare here, and the escaping rules * differ between Apache and nginx enough to be risky) * * Note the anchoring difference: Glob_Matcher anchors a pattern once * it contains a glob metacharacter. We do NOT anchor, because these * alternations are matched against the whole Cookie header (which * holds many `name=value` pairs), so an anchored rule would never fire. * Erring toward "matches more" is the safe direction here — the cost * of a false positive is a cache bypass (slower), while a false * negative is serving a page we promised not to (wrong). * * @param string[] $patterns * @return array{parts:string[],skipped:int} */ private static function build_alternation( array $patterns ): array { $parts = array(); $skipped = 0; foreach ( $patterns as $pattern ) { $pattern = trim( (string) $pattern ); if ( '' === $pattern ) { continue; } if ( '~' === $pattern[0] ) { ++$skipped; continue; } if ( strpos( $pattern, '[' ) !== false ) { ++$skipped; continue; } // Anything that could break out of the emitted config line is // unrepresentable — see SAFE_PATTERN_CHARS. if ( ! preg_match( self::SAFE_PATTERN_CHARS, $pattern ) ) { ++$skipped; continue; } if ( strlen( $pattern ) > self::MAX_PATTERN_LEN ) { ++$skipped; continue; } if ( count( $parts ) >= self::MAX_PATTERNS ) { ++$skipped; continue; } $parts[] = self::glob_to_server_regex( $pattern ); } return array( 'parts' => array_values( array_unique( $parts ) ), 'skipped' => $skipped, ); } /** * Escape a single glob pattern into a regex fragment safe to embed in * both an nginx `~*` test and an Apache RewriteCond. * * Everything is escaped by default; only unescaped `*` and `?` are * promoted to their regex equivalents. This is the function that keeps * a cookie name like `my.cookie[1]` from becoming an active pattern. */ private static function glob_to_server_regex( string $glob ): string { $out = ''; $len = strlen( $glob ); $escape = false; for ( $i = 0; $i < $len; $i++ ) { $ch = $glob[ $i ]; if ( $escape ) { $out .= preg_quote( $ch, '' ); $escape = false; continue; } if ( '\\' === $ch ) { $escape = true; continue; } if ( '*' === $ch ) { $out .= '.*'; continue; } if ( '?' === $ch ) { $out .= '.'; continue; } $out .= preg_quote( $ch, '' ); } // A trailing lone backslash would leave $escape set; emit nothing // for it rather than an unterminated escape that breaks the regex. return $out; } /** * How many patterns across both lists cannot be enforced by the web * server, so the UI can say so plainly instead of implying every rule * is active at the edge. * * @param array $cache_opts The `xspeed_module_cache` settings array. */ public static function unsupported_count( array $cache_opts ): int { $cookies = is_array( $cache_opts['excluded_cookies'] ?? null ) ? $cache_opts['excluded_cookies'] : array(); $uas = is_array( $cache_opts['bypass_user_agents'] ?? null ) ? $cache_opts['bypass_user_agents'] : array(); $c = self::cookie_rule( $cookies ); $u = self::user_agent_rule( $uas ); return (int) $c['skipped'] + (int) $u['skipped']; } }