Skip to main content

krabka_security/
java_regex.rs

1//! `java.util.regex` match and replacement semantics over the `regex` crate.
2//!
3//! Kafka's principal-mapping rules, `sasl.kerberos.principal.to.local.rules`
4//! and `ssl.principal.mapping.rules`, are Java regular expressions applied
5//! with `Matcher.matches()`, `Matcher.replaceAll` and `Matcher.replaceFirst`.
6//! The `regex` crate differs from Java in the three places those rules touch:
7//!
8//! - **Whole-input match.** `Matcher.matches()` succeeds when *some* match of
9//!   the pattern spans the whole input, found by backtracking. A leftmost-first
10//!   `find` that happens to stop short, as `a|ab` does on `ab`, is not the
11//!   same test. [`JavaPattern::matches`] runs a separately compiled
12//!   `\A(?:pattern)\z`.
13//! - **Group references in the replacement.** Java reads `$` then one digit,
14//!   and extends the number digit by digit only while the result is still a
15//!   group of the pattern, so `$1_$2` is group 1, `_`, group 2 and `$12` with
16//!   one group is group 1 then `2`. The `regex` crate takes the longest
17//!   `[_0-9A-Za-z]` run as the group name and expands an unknown group to
18//!   nothing. A backslash escapes the next character, and malformed
19//!   references are errors rather than empty text.
20//! - **Empty matches during `replaceAll`.** Java retries one character past an
21//!   empty match and allows an empty match right after a non-empty one, so
22//!   `x*` over `xx` with `[$0]` is `[xx][]`.
23//!
24//! - **Predefined classes.** Without `UNICODE_CHARACTER_CLASS`, Java's `\d`,
25//!   `\w`, `\s` and `\b` are ASCII-only; the `regex` crate's are Unicode.
26//!   [`JavaPattern::new`] rewrites them to their ASCII forms. It also makes
27//!   any escaped non-alphanumeric character a literal, as Java does, where
28//!   the `regex` crate rejects an escaped `,` or `@`.
29//!
30//! Otherwise the pattern dialect is the `regex` crate's: constructs it does
31//! not support, such as back-references and look-around, fail to compile.
32
33use regex::{Captures, Regex};
34
35/// Why a Java replacement string could not be expanded against a match.
36///
37/// Each variant is one of the `IllegalArgumentException` or
38/// `IndexOutOfBoundsException` messages `Matcher.appendReplacement` throws.
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40pub enum JavaReplacementError {
41    /// A trailing `\` with nothing after it.
42    #[error("character to be escaped is missing")]
43    MissingEscapedCharacter,
44    /// A trailing `$` with nothing after it.
45    #[error("Illegal group reference: group index is missing")]
46    MissingGroupIndex,
47    /// A `$` followed by something other than a digit or `{`.
48    #[error("Illegal group reference")]
49    IllegalGroupReference,
50    /// `${}`.
51    #[error("named capturing group has 0 length name")]
52    EmptyGroupName,
53    /// `${name` with no closing brace.
54    #[error("named capturing group is missing trailing '}}'")]
55    UnterminatedGroupName,
56    /// `${1a}`: a group name has to start with a letter.
57    #[error("capturing group name {{{0}}} starts with digit character")]
58    GroupNameStartsWithDigit(String),
59    /// `${name}` where the pattern has no group of that name.
60    #[error("No group with name {{{0}}}")]
61    NoGroupNamed(String),
62    /// `$n` where the pattern has fewer than `n` groups.
63    #[error("No group {0}")]
64    NoGroup(usize),
65    /// A match offset that does not fall on a character boundary of the
66    /// input. The `regex` crate never reports one; Java has no equivalent.
67    #[error("match offset {0} is not a character boundary of the input")]
68    MatchOffset(usize),
69}
70
71/// A pattern compiled for Java's `matches`, `replaceAll` and `replaceFirst`.
72#[derive(Debug, Clone)]
73pub struct JavaPattern {
74    source: String,
75    unanchored: Regex,
76    whole: Regex,
77}
78
79impl JavaPattern {
80    /// Compiles `pattern`.
81    ///
82    /// # Errors
83    ///
84    /// [`regex::Error`] when `pattern` is not a valid `regex` crate pattern.
85    pub fn new(pattern: &str) -> Result<Self, regex::Error> {
86        let translated = ascii_predefined_classes(pattern);
87        Ok(Self {
88            source: pattern.to_owned(),
89            unanchored: Regex::new(&translated)?,
90            whole: Regex::new(&format!(r"\A(?:{translated})\z"))?,
91        })
92    }
93
94    /// The pattern as written.
95    #[must_use]
96    pub fn as_str(&self) -> &str {
97        &self.source
98    }
99
100    /// Java's `Matcher.groupCount()`: the capturing groups, not counting the
101    /// whole match.
102    #[must_use]
103    pub fn group_count(&self) -> usize {
104        self.unanchored.captures_len() - 1
105    }
106
107    /// Java's `Matcher.matches()`: whether some match spans all of `text`.
108    #[must_use]
109    pub fn matches(&self, text: &str) -> bool {
110        self.whole.is_match(text)
111    }
112
113    /// Java's `Matcher.replaceAll(replacement)`.
114    ///
115    /// # Errors
116    ///
117    /// [`JavaReplacementError`] when `replacement` is malformed. As in Java,
118    /// that is only found out when the pattern matches somewhere.
119    pub fn replace_all(
120        &self,
121        text: &str,
122        replacement: &str,
123    ) -> Result<String, JavaReplacementError> {
124        self.replace(text, replacement, usize::MAX)
125    }
126
127    /// Java's `Matcher.replaceFirst(replacement)`.
128    ///
129    /// # Errors
130    ///
131    /// As [`JavaPattern::replace_all`].
132    pub fn replace_first(
133        &self,
134        text: &str,
135        replacement: &str,
136    ) -> Result<String, JavaReplacementError> {
137        self.replace(text, replacement, 1)
138    }
139
140    fn replace(
141        &self,
142        text: &str,
143        replacement: &str,
144        limit: usize,
145    ) -> Result<String, JavaReplacementError> {
146        let mut out = String::with_capacity(text.len());
147        let mut appended = 0;
148        let mut search = 0;
149        let mut replaced = 0;
150        while replaced < limit && search <= text.len() {
151            let Some(captures) = self.unanchored.captures_at(text, search) else {
152                break;
153            };
154            let whole = captures.get_match();
155            out.push_str(
156                text.get(appended..whole.start())
157                    .ok_or_else(|| JavaReplacementError::MatchOffset(whole.start()))?,
158            );
159            self.expand(&captures, replacement, &mut out)?;
160            appended = whole.end();
161            replaced += 1;
162            // `Matcher.find` resumes at the previous end, one character
163            // further when the previous match was empty.
164            search = if whole.is_empty() {
165                let rest = text
166                    .get(whole.end()..)
167                    .ok_or_else(|| JavaReplacementError::MatchOffset(whole.end()))?;
168                match rest.chars().next() {
169                    Some(next) => whole.end() + next.len_utf8(),
170                    None => break,
171                }
172            } else {
173                whole.end()
174            };
175        }
176        out.push_str(
177            text.get(appended..)
178                .ok_or(JavaReplacementError::MatchOffset(appended))?,
179        );
180        Ok(out)
181    }
182
183    /// `Matcher.appendExpandedReplacement`.
184    fn expand(
185        &self,
186        captures: &Captures<'_>,
187        replacement: &str,
188        out: &mut String,
189    ) -> Result<(), JavaReplacementError> {
190        let group_count = self.group_count();
191        let mut chars = replacement.chars().peekable();
192        while let Some(character) = chars.next() {
193            match character {
194                '\\' => out.push(
195                    chars
196                        .next()
197                        .ok_or(JavaReplacementError::MissingEscapedCharacter)?,
198                ),
199                '$' => {
200                    let group = match chars.next() {
201                        None => return Err(JavaReplacementError::MissingGroupIndex),
202                        Some('{') => {
203                            let mut name = String::new();
204                            while let Some(&next) = chars.peek()
205                                && next.is_ascii_alphanumeric()
206                            {
207                                name.push(next);
208                                chars.next();
209                            }
210                            if name.is_empty() {
211                                return Err(JavaReplacementError::EmptyGroupName);
212                            }
213                            if chars.next() != Some('}') {
214                                return Err(JavaReplacementError::UnterminatedGroupName);
215                            }
216                            if name.starts_with(|c: char| c.is_ascii_digit()) {
217                                return Err(JavaReplacementError::GroupNameStartsWithDigit(name));
218                            }
219                            self.unanchored
220                                .capture_names()
221                                .position(|candidate| candidate == Some(name.as_str()))
222                                .ok_or(JavaReplacementError::NoGroupNamed(name))?
223                        }
224                        Some(first) => {
225                            let mut group = first
226                                .to_digit(10)
227                                .ok_or(JavaReplacementError::IllegalGroupReference)?
228                                as usize;
229                            // "Capture the largest legal group string."
230                            while let Some(digit) = chars.peek().and_then(|c| c.to_digit(10)) {
231                                let longer = group * 10 + digit as usize;
232                                if longer > group_count {
233                                    break;
234                                }
235                                group = longer;
236                                chars.next();
237                            }
238                            if group > group_count {
239                                return Err(JavaReplacementError::NoGroup(group));
240                            }
241                            group
242                        }
243                    };
244                    if let Some(text) = captures.get(group) {
245                        out.push_str(text.as_str());
246                    }
247                }
248                literal => out.push(literal),
249            }
250        }
251        Ok(())
252    }
253}
254
255/// Rewrites Java's ASCII-only predefined classes and word boundaries into
256/// `regex` crate syntax with the same meaning. Each class becomes a bracketed
257/// class, which the `regex` crate also accepts nested inside another class.
258fn ascii_predefined_classes(pattern: &str) -> String {
259    let mut out = String::with_capacity(pattern.len());
260    let mut class_depth = 0_usize;
261    let mut chars = pattern.chars();
262    while let Some(character) = chars.next() {
263        match character {
264            '\\' => {
265                let Some(escaped) = chars.next() else {
266                    out.push('\\');
267                    break;
268                };
269                match escaped {
270                    'd' => out.push_str("[0-9]"),
271                    'D' => out.push_str("[^0-9]"),
272                    'w' => out.push_str("[0-9A-Za-z_]"),
273                    'W' => out.push_str("[^0-9A-Za-z_]"),
274                    's' => out.push_str(r"[\t\n\x0B\f\r ]"),
275                    'S' => out.push_str(r"[^\t\n\x0B\f\r ]"),
276                    'b' if class_depth == 0 => out.push_str(r"(?-u:\b)"),
277                    'B' if class_depth == 0 => out.push_str(r"(?-u:\B)"),
278                    // Java takes any escaped non-alphanumeric character
279                    // literally; the `regex` crate only accepts escaped
280                    // metacharacters.
281                    other if !other.is_ascii_alphanumeric() => {
282                        out.push_str(&regex::escape(other.encode_utf8(&mut [0; 4])));
283                    }
284                    other => {
285                        out.push('\\');
286                        out.push(other);
287                    }
288                }
289            }
290            '[' => {
291                class_depth += 1;
292                out.push('[');
293            }
294            ']' => {
295                class_depth = class_depth.saturating_sub(1);
296                out.push(']');
297            }
298            other => out.push(other),
299        }
300    }
301    out
302}
303
304#[cfg(test)]
305mod tests {
306    use assert2::check;
307
308    use super::*;
309
310    /// Each row was checked against `java.util.regex` on `OpenJDK` 21:
311    /// `Pattern.compile(pattern).matcher(text).replaceAll(replacement)`.
312    #[test]
313    fn replace_all_follows_java() {
314        // (pattern, text, replacement, expected)
315        let cases: &[(&str, &str, &str, Result<&str, JavaReplacementError>)] = &[
316            // `$1_` is group 1 then `_`, not a group named `1_`.
317            ("(a)(b)", "ab", "$1_$2", Ok("a_b")),
318            ("(a)(b)", "ab", "$1suffix", Ok("asuffix")),
319            // `$12` with two groups is group 1 then a literal `2`.
320            ("(a)(b)", "ab", "$12", Ok("a2")),
321            // With eleven groups `$11` is group 11 and `$111` is group 11 then `1`.
322            (
323                "(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)(k)",
324                "abcdefghijk",
325                "$11,$111,$1a",
326                Ok("k,k1,aa"),
327            ),
328            ("(a)(b)", "ab", "$3", Err(JavaReplacementError::NoGroup(3))),
329            ("(a)(b)", "ab", r"\$1", Ok("$1")),
330            ("(a)", "a", r"x\\", Ok(r"x\")),
331            ("(a)", "a", r"x\/y", Ok("x/y")),
332            (
333                "(a)",
334                "a",
335                "x$",
336                Err(JavaReplacementError::MissingGroupIndex),
337            ),
338            (
339                "(a)",
340                "a",
341                r"x\",
342                Err(JavaReplacementError::MissingEscapedCharacter),
343            ),
344            (
345                "(a)",
346                "a",
347                "$x",
348                Err(JavaReplacementError::IllegalGroupReference),
349            ),
350            ("(?<nm>a)(b)", "ab", "${nm}-$2", Ok("a-b")),
351            ("(a)", "a", "${}", Err(JavaReplacementError::EmptyGroupName)),
352            (
353                "(a)",
354                "a",
355                "${nm",
356                Err(JavaReplacementError::UnterminatedGroupName),
357            ),
358            (
359                "(a)",
360                "a",
361                "${1a}",
362                Err(JavaReplacementError::GroupNameStartsWithDigit(
363                    "1a".to_owned(),
364                )),
365            ),
366            (
367                "(a)",
368                "a",
369                "${zz}",
370                Err(JavaReplacementError::NoGroupNamed("zz".to_owned())),
371            ),
372            // A group that did not participate expands to nothing.
373            ("(a)|(b)", "b", "[$1]", Ok("[]")),
374            // Leftmost-first alternation, then the scan resumes after `a`.
375            ("a|ab", "ab", "X", Ok("Xb")),
376            // An empty match right after a non-empty one is still replaced.
377            ("x*", "xx", "[$0]", Ok("[xx][]")),
378            ("x*", "", "-", Ok("-")),
379            ("", "ab", "-", Ok("-a-b-")),
380            ("a*", "baaa", "X", Ok("XbXX")),
381            // No match: a malformed replacement is never looked at.
382            ("z", "ab", "$", Ok("ab")),
383        ];
384        for (pattern, text, replacement, expected) in cases {
385            let compiled = JavaPattern::new(pattern).expect("pattern compiles");
386            check!(
387                compiled.replace_all(text, replacement).as_deref() == expected.as_deref(),
388                "{pattern} over {text} with {replacement}"
389            );
390        }
391    }
392
393    #[test]
394    fn replace_first_stops_after_one_match() {
395        let compiled = JavaPattern::new("a").expect("pattern compiles");
396        check!(compiled.replace_first("banana", "o").as_deref() == Ok("bonana"));
397    }
398
399    /// `Matcher.matches()` backtracks to any whole-input match, which a
400    /// leftmost-first `find` can miss.
401    #[test]
402    fn matches_is_a_whole_input_match() {
403        // (pattern, text, matches)
404        let cases = [
405            ("a|ab", "ab", true),
406            ("CN=(.*?)", "CN=alice", true),
407            ("CN=(.*?),", "CN=alice,OU=x", false),
408            ("ali", "alice", false),
409            ("^DC=(a)|(b)$", "DC=b", false),
410            ("", "", true),
411            // Java's predefined classes are ASCII-only.
412            (r"CN=(\d+)", "CN=12", true),
413            (r"CN=(\d+)", "CN=\u{663}", false),
414            (r"\w+", "\u{e9}", false),
415            (r"\W", "\u{e9}", true),
416            (r"\s", "\u{a0}", false),
417            (r"[\d\s]+", "1 2", true),
418            (r"a\b.", "a\u{e9}", true),
419            (r"\[\d\]", "[1]", true),
420            // Escaped punctuation is literal, in and out of a class.
421            (r"CN=a\,b\@c\/d", "CN=a,b@c/d", true),
422            (r"[\,\]\-]+", ",]-", true),
423            (r"\\,", r"\,", true),
424        ];
425        for (pattern, text, expected) in cases {
426            let compiled = JavaPattern::new(pattern).expect("pattern compiles");
427            check!(
428                compiled.matches(text) == expected,
429                "{pattern} against {text}"
430            );
431        }
432    }
433
434    #[test]
435    fn group_count_excludes_the_whole_match() {
436        check!(
437            JavaPattern::new("(a)(?:b)(?<c>c)")
438                .expect("compiles")
439                .group_count()
440                == 2
441        );
442    }
443}