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(®ex::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}