PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-glob-matcher.php

class-glob-matcher.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.4, at includes/class-glob-matcher.php

276 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Glob_Matcher — translate user-friendly glob patterns to PCRE for the
4 * cache engine's exclusion checks.
5 *
6 * Supported syntax (shell-style glob, NOT full PCRE):
7 * * → match any run of characters (greedy), including '/'
8 * ? → match exactly one character
9 * [abc] → character class
10 * \* → literal asterisk (escape)
11 *
12 * Compiles each user pattern once, caches the compiled regex in a
13 * static array for the request, then matches with a single preg_match.
14 *
15 * A bare substring (no glob metacharacters) keeps the legacy "contains"
16 * semantics: `/cart` matches `/cart`, `/cart/items`, `/foo/cart/bar`.
17 * Adding ANY of `* ? [` switches the pattern to anchored glob mode:
18 * `/cart/*` matches `/cart/items` but NOT `/foo/cart/bar`.
19 *
20 * RAW REGEX: a pattern prefixed with `~` is treated as a raw PCRE
21 * (unanchored) — `~utm_[a-z0-9_-]+` matches like a real regex. This lets
22 * users paste LiteSpeed / WP Rocket exclusion lists (which are regex)
23 * verbatim by adding the `~` marker. Invalid or over-long regex patterns
24 * are rejected safely (never match, never fatal, never match-everything).
25 *
26 * NAME MODE (`matches_name()` / `any_match_name()`) matches an identifier —
27 * a query parameter name — instead of a URL, and there "contains" is the
28 * wrong contract: `ref` would swallow `preference`, `product_ref` and
29 * `referrer`, collapsing genuinely different pages onto one cache key. In
30 * name mode a bare pattern is an exact, case-insensitive match, and BOTH
31 * the glob and `~regex` forms are anchored, so `~utm_[a-z0-9_-]+` matches
32 * `utm_source` but not `my_utm_source`. URL matching keeps the contains
33 * semantics above — a path exclusion of `/cart` is meant to catch
34 * `/cart/items`.
35 *
36 * Cookie names deliberately do NOT use name mode: the shipped defaults are
37 * prefixes of hash-suffixed real cookies (`comment_author` must match
38 * `comment_author_<hash>`), so tightening them would serve shared cached
39 * pages to commenters and logged-in-adjacent visitors.
40 *
41 * @package XSpeed
42 */
43
44 declare(strict_types=1);
45
46 namespace XSpeed;
47
48 defined( 'ABSPATH' ) || exit;
49
50 final class Glob_Matcher {
51
52 /**
53 * @var array<string,string> pattern => compiled regex
54 */
55 private static $compiled = array();
56
57 /**
58 * Does any of `$patterns` match `$subject`?
59 *
60 * @param string[] $patterns
61 */
62 public static function any_match( array $patterns, string $subject ): bool {
63 foreach ( $patterns as $p ) {
64 $p = (string) $p;
65 if ( '' === $p ) {
66 continue;
67 }
68 if ( self::matches( $p, $subject ) ) {
69 return true;
70 }
71 }
72 return false;
73 }
74
75 public static function matches( string $pattern, string $subject ): bool {
76 $regex = self::compile( $pattern );
77 // Anchored glob (regex returned starts with '#^') vs substring
78 // (regex returned starts with '#'). Both use preg_match the
79 // same way; the anchoring is baked into the pattern.
80 return 1 === preg_match( $regex, $subject );
81 }
82
83 /**
84 * Does any of `$patterns` match the identifier `$name`?
85 *
86 * Name mode: whole-string matching for every pattern form. See the class
87 * docblock for why an identifier must not use contains semantics.
88 *
89 * @param string[] $patterns
90 */
91 public static function any_match_name( array $patterns, string $name ): bool {
92 foreach ( $patterns as $p ) {
93 $p = (string) $p;
94 if ( '' === $p ) {
95 continue;
96 }
97 if ( self::matches_name( $p, $name ) ) {
98 return true;
99 }
100 }
101 return false;
102 }
103
104 /**
105 * Match one pattern against an identifier, whole-string and
106 * case-insensitively.
107 */
108 public static function matches_name( string $pattern, string $name ): bool {
109 return 1 === preg_match( self::compile_name( $pattern ), $name );
110 }
111
112 /**
113 * Compile a pattern for name mode. Cached separately from URL mode —
114 * the same pattern compiles to a different regex in each — under a key
115 * no user pattern can produce, since a NUL byte cannot survive the
116 * settings sanitizer.
117 */
118 public static function compile_name( string $pattern ): string {
119 $cache_key = "\0name:" . $pattern;
120 if ( isset( self::$compiled[ $cache_key ] ) ) {
121 return self::$compiled[ $cache_key ];
122 }
123
124 if ( '' !== $pattern && '~' === $pattern[0] ) {
125 // Anchored, unlike URL mode: an unanchored `~utm_[a-z0-9_-]+`
126 // still contains-matches `my_utm_source`, which is the very
127 // over-match name mode exists to stop.
128 $regex = self::compile_regex( substr( $pattern, 1 ), true );
129 } elseif ( preg_match( '/(?<!\\\\)[*?\[]/', $pattern ) ) {
130 $regex = '#^' . self::glob_to_regex( $pattern ) . '$#i';
131 } else {
132 // Bare pattern → exact name. Resolve `\X` → `X` first so an
133 // escaped metacharacter matches itself.
134 $resolved = preg_replace( '/\\\\(.)/', '$1', $pattern );
135 $regex = '#^' . preg_quote( (string) $resolved, '#' ) . '$#i';
136 }
137
138 self::$compiled[ $cache_key ] = $regex;
139 return $regex;
140 }
141
142 /**
143 * Compile a user pattern to a PCRE delimited with `#`. Cached
144 * for the request lifetime.
145 */
146 public static function compile( string $pattern ): string {
147 if ( isset( self::$compiled[ $pattern ] ) ) {
148 return self::$compiled[ $pattern ];
149 }
150 // Raw-regex mode: a leading `~` marks the rest as a PCRE pattern.
151 // We validate it once and store either the usable regex or a
152 // never-matching sentinel, so a malformed user pattern degrades to
153 // "matches nothing" instead of fataling or matching everything.
154 if ( '' !== $pattern && '~' === $pattern[0] ) {
155 $regex = self::compile_regex( substr( $pattern, 1 ) );
156 self::$compiled[ $pattern ] = $regex;
157 return $regex;
158 }
159 // Decide mode based on UNESCAPED glob metacharacters only.
160 // `\*` alone keeps the pattern in substring mode (with escapes
161 // resolved); `/cart/*` flips to anchored glob mode.
162 $has_unescaped_glob = (bool) preg_match( '/(?<!\\\\)[*?\[]/', $pattern );
163 if ( $has_unescaped_glob ) {
164 $regex = '#^' . self::glob_to_regex( $pattern ) . '$#';
165 } else {
166 // Substring "contains" mode. Resolve `\X` → `X` first so
167 // `\*` matches a literal asterisk anywhere in the subject.
168 $resolved = preg_replace( '/\\\\(.)/', '$1', $pattern );
169 $regex = '#' . preg_quote( (string) $resolved, '#' ) . '#';
170 }
171 self::$compiled[ $pattern ] = $regex;
172 return $regex;
173 }
174
175 /**
176 * A delimited PCRE that can never match any input — used as the safe
177 * fallback for invalid / over-long user regex patterns. `(?!)` is the
178 * empty negative lookahead: it fails at every position.
179 */
180 private const NEVER = '#(?!)#';
181
182 /**
183 * Validate + delimit a user-supplied raw regex (the part after `~`).
184 * Returns a `#…#` delimited PCRE (unanchored, so it matches like a
185 * "contains" regex — anchored and case-insensitive in name mode), or the
186 * NEVER sentinel when the pattern is empty, too long, or not a valid
187 * PCRE. We never let a bad pattern through: a regex that errors at match
188 * time would otherwise emit warnings on every cached request.
189 *
190 * @param string $body The pattern after the `~` marker.
191 * @param bool $anchored Wrap in `^(?:…)$` and match case-insensitively.
192 */
193 private static function compile_regex( string $body, bool $anchored = false ): string {
194 // Cap length to keep compile + match cheap and bound backtracking
195 // exposure from pathological user input.
196 if ( '' === $body || strlen( $body ) > 200 ) {
197 return self::NEVER;
198 }
199 $escaped = str_replace( '#', '\\#', $body );
200 // Group before anchoring so a top-level alternation (`a|b`) anchors
201 // as a whole rather than binding `^` to the first branch only.
202 $regex = $anchored ? '#^(?:' . $escaped . ')$#i' : '#' . $escaped . '#';
203 // Validate by compiling against an empty subject. preg_match returns
204 // false on a malformed pattern; suppress the warning it emits.
205 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- intentional: invalid user regex must degrade to never-match, not warn.
206 if ( false === @preg_match( $regex, '' ) ) {
207 return self::NEVER;
208 }
209 return $regex;
210 }
211
212 /**
213 * Translate glob syntax → regex body (no delimiters, no anchors).
214 * Mirrors fnmatch's FNM_PATHNAME-disabled semantics: `*` matches
215 * across `/` so `/cart/*` correctly catches `/cart/items/sub`.
216 */
217 private static function glob_to_regex( string $glob ): string {
218 $out = '';
219 $in_class = false;
220 $len = strlen( $glob );
221 $escape = false;
222
223 for ( $i = 0; $i < $len; $i++ ) {
224 $ch = $glob[ $i ];
225
226 if ( $escape ) {
227 $out .= preg_quote( $ch, '#' );
228 $escape = false;
229 continue;
230 }
231
232 if ( '\\' === $ch ) {
233 $escape = true;
234 continue;
235 }
236
237 if ( $in_class ) {
238 if ( ']' === $ch ) {
239 $out .= ']';
240 $in_class = false;
241 } else {
242 // Inside a character class, dash + letters are passed
243 // through; we still preg_quote dangerous chars.
244 $out .= preg_quote( $ch, '#' );
245 }
246 continue;
247 }
248
249 switch ( $ch ) {
250 case '*':
251 $out .= '.*';
252 break;
253 case '?':
254 $out .= '.';
255 break;
256 case '[':
257 $out .= '[';
258 $in_class = true;
259 break;
260 default:
261 $out .= preg_quote( $ch, '#' );
262 }
263 }
264
265 return $out;
266 }
267
268 /**
269 * Test-only: clear the compile cache. Production code never needs
270 * this (PHP request lifetime handles it).
271 */
272 public static function reset_cache(): void {
273 self::$compiled = array();
274 }
275 }
276