Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.75% covered (warning)
84.75%
50 / 59
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Escaper
84.75% covered (warning)
84.75%
50 / 59
60.00% covered (warning)
60.00%
6 / 10
21.42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 escapeQuotes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 fixupQueryStringPart
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 fixupWholeQueryString
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
4.02
 lowercaseMatched
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 balanceQuotes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 unbalancedQuotes
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 unescape
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAllowLeadingWildcard
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch\Search;
4
5/**
6 * Escapes queries.
7 *
8 * @license GPL-2.0-or-later
9 */
10class Escaper {
11
12    /**
13     * @var string MediaWiki language code
14     */
15    private $language;
16
17    /**
18     * Allow leading wildcards?
19     * @var bool
20     */
21    private $allowLeadingWildcard;
22
23    /**
24     * @param string $language MediaWiki language code
25     * @param bool $allowLeadingWildcard
26     */
27    public function __construct( $language, $allowLeadingWildcard = true ) {
28        $this->language = $language;
29        $this->allowLeadingWildcard = $allowLeadingWildcard;
30    }
31
32    /**
33     * @param string $text
34     * @return string
35     */
36    public function escapeQuotes( $text ) {
37        if ( $this->language === 'he' ) {
38            // Hebrew uses the double quote (") character as a standin for quotation marks (“”)
39            // which delineate phrases.  It also uses double quotes as a standin for another
40            // character (״), call a Gershayim, which mark acronyms.  Here we guess if the intent
41            // was to mark a phrase, in which case we leave the quotes alone, or to mark an
42            // acronym, in which case we escape them.
43            return preg_replace( '/(?<=[^\s\\\\])"(?=\S)/u', '\\"', $text );
44        }
45        return $text;
46    }
47
48    /**
49     * Make sure the query string part is well formed by escaping some syntax that we don't
50     * want users to get direct access to and making sure quotes are balanced.
51     * These special characters _aren't_ escaped:
52     * * and ?: Do a wildcard search against the stemmed text which isn't strictly a good
53     * idea but this is so rarely used that adding extra code to flip prefix searches into
54     * real prefix searches isn't really worth it.
55     * ~: Do a fuzzy match against the stemmed text which isn't strictly a good idea but it
56     * gets the job done and fuzzy matches are a really rarely used feature to be creating an
57     * extra index for.
58     * ": Perform a phrase search for the quoted term.  If the "s aren't balanced we insert one
59     * at the end of the term to make sure elasticsearch doesn't barf at us.
60     *
61     * @param string $string
62     * @return string
63     */
64    public function fixupQueryStringPart( $string ) {
65        // Escape characters that can be escaped with \\
66        $string = preg_replace( '/(
67                \(|     (?# no user supplied groupings)
68                \)|
69                \{|     (?# no exclusive range queries)
70                }|
71                \[|     (?# no inclusive range queries either)
72                ]|
73                \^|     (?# no user supplied boosts at this point, though I cant think why)
74                :|        (?# no specifying your own fields)
75                \\\(?!") (?# the only acceptable escaping is for quotes)
76            )/x', '\\\$1', $string );
77        // Forward slash escaping doesn't work properly in all environments so we just eat them.   Nom.
78        $string = str_replace( '/', ' ', $string );
79
80        // Elasticsearch's query strings can't abide unbalanced quotes
81        return $this->balanceQuotes( $string );
82    }
83
84    /**
85     * Make sure that all operators and lucene syntax is used correctly in the query string
86     * and store if this is a fuzzy query.
87     * If it isn't then the syntax escaped so it becomes part of the query text.
88     *
89     * @param string $string
90     * @return string fixed up query string
91     */
92    public function fixupWholeQueryString( $string ) {
93        $escapeBadSyntax = static function ( $matches ) {
94            return preg_replace( '/(?=[^\s\w])/', '\\', $matches[0] );
95        };
96
97        // Be careful when editing this method because the ordering of the replacements matters.
98
99        // Escape ~ that don't follow a term or a quote
100        $string = preg_replace_callback( '/(?<![\w"])~/u', $escapeBadSyntax, $string );
101
102        // When allow leading wildcard is disabled elasticsearch will report an
103        // error if these are unescaped. Escape ? and * that don't follow a term.
104        if ( !$this->allowLeadingWildcard ) {
105            $string = preg_replace_callback( '/(?<!\w)[?*]/u', $escapeBadSyntax, $string );
106        }
107
108        // Reduce token ranges to bare tokens without the < or >
109        $string = preg_replace( '/[<>]+(\S)/u', '$1', $string );
110
111        // Turn bad fuzzy searches into searches that contain a ~ and set $this->fuzzyQuery for good ones.
112        $string = preg_replace_callback( '/(?<leading>\w)~(?<trailing>\S*)/u',
113            static function ( $matches ) {
114                if ( preg_match( '/^[0-2]?$/', $matches[ 'trailing' ] ) ) {
115                    return $matches[ 0 ];
116                } else {
117                    return $matches[ 'leading' ] . '\\~' .
118                        preg_replace( '/(?<!\\\\)~/', '\~', $matches[ 'trailing' ] );
119                }
120            }, $string );
121
122        // Turn bad proximity searches into searches that contain a ~
123        $string = preg_replace_callback( '/"~(?<trailing>\S*)/u', static function ( $matches ) {
124            if ( preg_match( '/\d+/', $matches[ 'trailing' ] ) ) {
125                return $matches[ 0 ];
126            } else {
127                return '"\\~' . $matches[ 'trailing' ];
128            }
129        }, $string );
130
131        // Escape +, -, and ! when not immediately followed by a term or when immediately
132        // prefixed with a term.  Catches "foo-bar", "foo- bar", "foo - bar".  The only
133        // acceptable use is "foo -bar" and "-bar foo".
134        $string = preg_replace_callback( '/[+\-!]+(?!\w)/u', $escapeBadSyntax, $string );
135        $string = preg_replace_callback( '/(?<!^|[ \\\\])[+\-!]+/u', $escapeBadSyntax, $string );
136
137        // Escape || when not between terms
138        $string = preg_replace_callback( '/^\s*\|\|/u', $escapeBadSyntax, $string );
139        $string = preg_replace_callback( '/\|\|\s*$/u', $escapeBadSyntax, $string );
140
141        // Lowercase AND and OR when not surrounded on both sides by a term.
142        // Lowercase NOT when it doesn't have a term after it.
143        $string = preg_replace_callback( '/^\s*(?:AND|OR)\b|\b(?:AND|OR|NOT)\s*$/u',
144            [ self::class, 'lowercaseMatched' ], $string );
145        $string = preg_replace_callback( '/\b(?:AND|OR|NOT)\s+(?=AND\b|OR\b|NOT\b)/u',
146            [ self::class, 'lowercaseMatched' ], $string );
147
148        return $string;
149    }
150
151    /**
152     * @param string[] $matches
153     * @return string
154     */
155    private static function lowercaseMatched( $matches ) {
156        return strtolower( $matches[ 0 ] );
157    }
158
159    /**
160     * @param string $text
161     * @return string
162     */
163    public function balanceQuotes( $text ) {
164        if ( $this->unbalancedQuotes( $text ) ) {
165            $text .= '"';
166        }
167        return $text;
168    }
169
170    /**
171     * @param string $text
172     * @param int $from
173     * @param int $to
174     * @return bool true if there are unbalanced quotes in the [$from, $to] range.
175     */
176    public function unbalancedQuotes( $text, $from = 0, $to = -1 ) {
177        $to = $to < 0 ? strlen( $text ) : $to;
178        $inQuote = false;
179        $inEscape = false;
180        for ( $i = $from; $i < $to; $i++ ) {
181            if ( $inEscape ) {
182                $inEscape = false;
183                continue;
184            }
185            switch ( $text[ $i ] ) {
186                case '"':
187                    $inQuote = !$inQuote;
188                    break;
189                case '\\':
190                    $inEscape = true;
191            }
192        }
193        return $inQuote;
194    }
195
196    /**
197     * Unescape a given string
198     * @param string $query string to unescape
199     * @param string $escapeChar escape sequence
200     * @return string
201     */
202    public function unescape( $query, $escapeChar = '\\' ) {
203        $escapeChar = preg_quote( $escapeChar, '/' );
204        return preg_replace( "/$escapeChar(.)/u", '$1', $query );
205    }
206
207    /**
208     * Is leading wildcard allowed?
209     *
210     * @return bool
211     */
212    public function getAllowLeadingWildcard() {
213        return $this->allowLeadingWildcard;
214    }
215
216    /**
217     * @return string
218     */
219    public function getLanguage() {
220        return $this->language;
221    }
222}