pattern => compiled regex */ private static $compiled = array(); /** * Does any of `$patterns` match `$subject`? * * @param string[] $patterns */ public static function any_match( array $patterns, string $subject ): bool { foreach ( $patterns as $p ) { $p = (string) $p; if ( '' === $p ) { continue; } if ( self::matches( $p, $subject ) ) { return true; } } return false; } public static function matches( string $pattern, string $subject ): bool { $regex = self::compile( $pattern ); // Anchored glob (regex returned starts with '#^') vs substring // (regex returned starts with '#'). Both use preg_match the // same way; the anchoring is baked into the pattern. return 1 === preg_match( $regex, $subject ); } /** * Compile a user pattern to a PCRE delimited with `#`. Cached * for the request lifetime. */ public static function compile( string $pattern ): string { if ( isset( self::$compiled[ $pattern ] ) ) { return self::$compiled[ $pattern ]; } // Raw-regex mode: a leading `~` marks the rest as a PCRE pattern. // We validate it once and store either the usable regex or a // never-matching sentinel, so a malformed user pattern degrades to // "matches nothing" instead of fataling or matching everything. if ( '' !== $pattern && '~' === $pattern[0] ) { $regex = self::compile_regex( substr( $pattern, 1 ) ); self::$compiled[ $pattern ] = $regex; return $regex; } // Decide mode based on UNESCAPED glob metacharacters only. // `\*` alone keeps the pattern in substring mode (with escapes // resolved); `/cart/*` flips to anchored glob mode. $has_unescaped_glob = (bool) preg_match( '/(? 200 ) { return self::NEVER; } $regex = '#' . str_replace( '#', '\\#', $body ) . '#'; // Validate by compiling against an empty subject. preg_match returns // false on a malformed pattern; suppress the warning it emits. // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- intentional: invalid user regex must degrade to never-match, not warn. if ( false === @preg_match( $regex, '' ) ) { return self::NEVER; } return $regex; } /** * Translate glob syntax → regex body (no delimiters, no anchors). * Mirrors fnmatch's FNM_PATHNAME-disabled semantics: `*` matches * across `/` so `/cart/*` correctly catches `/cart/items/sub`. */ private static function glob_to_regex( string $glob ): string { $out = ''; $in_class = false; $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 ( $in_class ) { if ( ']' === $ch ) { $out .= ']'; $in_class = false; } else { // Inside a character class, dash + letters are passed // through; we still preg_quote dangerous chars. $out .= preg_quote( $ch, '#' ); } continue; } switch ( $ch ) { case '*': $out .= '.*'; break; case '?': $out .= '.'; break; case '[': $out .= '['; $in_class = true; break; default: $out .= preg_quote( $ch, '#' ); } } return $out; } /** * Test-only: clear the compile cache. Production code never needs * this (PHP request lifetime handles it). */ public static function reset_cache(): void { self::$compiled = array(); } }