PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.6
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.0.6, at includes/class-glob-matcher.php

196 lines 6.0 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 * @package XSpeed
27 */
28
29 declare(strict_types=1);
30
31 namespace XSpeed;
32
33 defined( 'ABSPATH' ) || exit;
34
35 final class Glob_Matcher {
36
37 /**
38 * @var array<string,string> pattern => compiled regex
39 */
40 private static $compiled = array();
41
42 /**
43 * Does any of `$patterns` match `$subject`?
44 *
45 * @param string[] $patterns
46 */
47 public static function any_match( array $patterns, string $subject ): bool {
48 foreach ( $patterns as $p ) {
49 $p = (string) $p;
50 if ( '' === $p ) {
51 continue;
52 }
53 if ( self::matches( $p, $subject ) ) {
54 return true;
55 }
56 }
57 return false;
58 }
59
60 public static function matches( string $pattern, string $subject ): bool {
61 $regex = self::compile( $pattern );
62 // Anchored glob (regex returned starts with '#^') vs substring
63 // (regex returned starts with '#'). Both use preg_match the
64 // same way; the anchoring is baked into the pattern.
65 return 1 === preg_match( $regex, $subject );
66 }
67
68 /**
69 * Compile a user pattern to a PCRE delimited with `#`. Cached
70 * for the request lifetime.
71 */
72 public static function compile( string $pattern ): string {
73 if ( isset( self::$compiled[ $pattern ] ) ) {
74 return self::$compiled[ $pattern ];
75 }
76 // Raw-regex mode: a leading `~` marks the rest as a PCRE pattern.
77 // We validate it once and store either the usable regex or a
78 // never-matching sentinel, so a malformed user pattern degrades to
79 // "matches nothing" instead of fataling or matching everything.
80 if ( '' !== $pattern && '~' === $pattern[0] ) {
81 $regex = self::compile_regex( substr( $pattern, 1 ) );
82 self::$compiled[ $pattern ] = $regex;
83 return $regex;
84 }
85 // Decide mode based on UNESCAPED glob metacharacters only.
86 // `\*` alone keeps the pattern in substring mode (with escapes
87 // resolved); `/cart/*` flips to anchored glob mode.
88 $has_unescaped_glob = (bool) preg_match( '/(?<!\\\\)[*?\[]/', $pattern );
89 if ( $has_unescaped_glob ) {
90 $regex = '#^' . self::glob_to_regex( $pattern ) . '$#';
91 } else {
92 // Substring "contains" mode. Resolve `\X` → `X` first so
93 // `\*` matches a literal asterisk anywhere in the subject.
94 $resolved = preg_replace( '/\\\\(.)/', '$1', $pattern );
95 $regex = '#' . preg_quote( (string) $resolved, '#' ) . '#';
96 }
97 self::$compiled[ $pattern ] = $regex;
98 return $regex;
99 }
100
101 /**
102 * A delimited PCRE that can never match any input — used as the safe
103 * fallback for invalid / over-long user regex patterns. `(?!)` is the
104 * empty negative lookahead: it fails at every position.
105 */
106 private const NEVER = '#(?!)#';
107
108 /**
109 * Validate + delimit a user-supplied raw regex (the part after `~`).
110 * Returns a `#…#` delimited PCRE (unanchored, so it matches like a
111 * "contains" regex), or the NEVER sentinel when the pattern is empty,
112 * too long, or not a valid PCRE. We never let a bad pattern through:
113 * a regex that errors at match time would otherwise emit warnings on
114 * every cached request.
115 */
116 private static function compile_regex( string $body ): string {
117 // Cap length to keep compile + match cheap and bound backtracking
118 // exposure from pathological user input.
119 if ( '' === $body || strlen( $body ) > 200 ) {
120 return self::NEVER;
121 }
122 $regex = '#' . str_replace( '#', '\\#', $body ) . '#';
123 // Validate by compiling against an empty subject. preg_match returns
124 // false on a malformed pattern; suppress the warning it emits.
125 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- intentional: invalid user regex must degrade to never-match, not warn.
126 if ( false === @preg_match( $regex, '' ) ) {
127 return self::NEVER;
128 }
129 return $regex;
130 }
131
132 /**
133 * Translate glob syntax → regex body (no delimiters, no anchors).
134 * Mirrors fnmatch's FNM_PATHNAME-disabled semantics: `*` matches
135 * across `/` so `/cart/*` correctly catches `/cart/items/sub`.
136 */
137 private static function glob_to_regex( string $glob ): string {
138 $out = '';
139 $in_class = false;
140 $len = strlen( $glob );
141 $escape = false;
142
143 for ( $i = 0; $i < $len; $i++ ) {
144 $ch = $glob[ $i ];
145
146 if ( $escape ) {
147 $out .= preg_quote( $ch, '#' );
148 $escape = false;
149 continue;
150 }
151
152 if ( '\\' === $ch ) {
153 $escape = true;
154 continue;
155 }
156
157 if ( $in_class ) {
158 if ( ']' === $ch ) {
159 $out .= ']';
160 $in_class = false;
161 } else {
162 // Inside a character class, dash + letters are passed
163 // through; we still preg_quote dangerous chars.
164 $out .= preg_quote( $ch, '#' );
165 }
166 continue;
167 }
168
169 switch ( $ch ) {
170 case '*':
171 $out .= '.*';
172 break;
173 case '?':
174 $out .= '.';
175 break;
176 case '[':
177 $out .= '[';
178 $in_class = true;
179 break;
180 default:
181 $out .= preg_quote( $ch, '#' );
182 }
183 }
184
185 return $out;
186 }
187
188 /**
189 * Test-only: clear the compile cache. Production code never needs
190 * this (PHP request lifetime handles it).
191 */
192 public static function reset_cache(): void {
193 self::$compiled = array();
194 }
195 }
196