1use std::collections::HashMap;
18use std::sync::{Arc, LazyLock, RwLock};
19
20const REGEX_SIZE_LIMIT_BYTES: usize = 64 << 20;
25
26static PATTERN_CACHE: LazyLock<RwLock<HashMap<String, Option<Arc<CompiledPattern>>>>> =
32 LazyLock::new(|| RwLock::new(HashMap::new()));
33
34#[derive(Debug)]
39pub enum CompiledPattern {
40 Fast(regex::Regex),
41 Fancy(fancy_regex::Regex),
42}
43
44impl CompiledPattern {
45 #[must_use]
50 pub fn is_match(&self, text: &str) -> bool {
51 match self {
52 CompiledPattern::Fast(re) => re.is_match(text),
53 CompiledPattern::Fancy(re) => re.is_match(text).unwrap_or(false),
54 }
55 }
56}
57
58#[must_use]
66pub fn compile(pattern: &str) -> Option<Arc<CompiledPattern>> {
67 if let Some(cached) = PATTERN_CACHE.read().unwrap_or_else(|poisoned| poisoned.into_inner()).get(pattern) {
68 return cached.clone();
69 }
70 let compiled = compile_uncached(pattern).map(Arc::new);
71 PATTERN_CACHE
72 .write()
73 .unwrap_or_else(|poisoned| poisoned.into_inner())
74 .insert(pattern.to_string(), compiled.clone());
75 compiled
76}
77
78fn compile_uncached(pattern: &str) -> Option<CompiledPattern> {
79 if let Ok(re) = build_fast(pattern) {
80 return Some(CompiledPattern::Fast(re));
81 }
82 let normalized = normalize(pattern);
83 if let Ok(re) = build_fast(&normalized) {
84 return Some(CompiledPattern::Fast(re));
85 }
86 if let Ok(re) = fancy_regex::Regex::new(pattern) {
87 return Some(CompiledPattern::Fancy(re));
88 }
89 if let Ok(re) = fancy_regex::Regex::new(&normalized) {
90 return Some(CompiledPattern::Fancy(re));
91 }
92 None
93}
94
95#[must_use]
101pub fn is_service_valid(pattern: &str) -> bool {
102 compile(pattern).is_some()
103}
104
105#[must_use]
109pub fn anchor_allowed_pattern(pattern: &str) -> String {
110 match (pattern.starts_with('^'), pattern.ends_with('$')) {
111 (true, true) => pattern.to_string(),
112 (true, false) => format!("{pattern}$"),
113 (false, true) => format!("^{pattern}"),
114 (false, false) => format!("^{pattern}$"),
115 }
116}
117
118#[must_use]
123pub fn default_matches_pattern(pattern: &str, value: &str, is_comma_delimited: bool) -> Option<bool> {
124 let compiled = compile(&anchor_allowed_pattern(pattern))?;
125 if is_comma_delimited {
126 Some(value.split(',').all(|element| compiled.is_match(element.trim())))
127 } else {
128 Some(compiled.is_match(value))
129 }
130}
131
132fn build_fast(pattern: &str) -> Result<regex::Regex, regex::Error> {
133 regex::RegexBuilder::new(pattern).size_limit(REGEX_SIZE_LIMIT_BYTES).build()
134}
135
136static QUANTIFIED_LOOKAROUND: LazyLock<regex::Regex> =
137 LazyLock::new(|| regex::Regex::new(r"(\(\?[=!][^)]*\))[+*]").expect("QUANTIFIED_LOOKAROUND is a valid regex"));
138
139fn normalize(pattern: &str) -> String {
143 let mut out = pattern.replace(r"\Z", r"\z");
144 out = convert_unicode_escapes(&out);
145 out = expand_posix_classes(&out);
146 out = rewrite_unicode_other_complement(&out);
147 out = repair_char_classes(&out);
148 QUANTIFIED_LOOKAROUND.replace_all(&out, "$1").into_owned()
149}
150
151fn convert_unicode_escapes(pattern: &str) -> String {
164 let chars: Vec<char> = pattern.chars().collect();
165 let mut out = String::with_capacity(pattern.len());
166 let mut index = 0;
167 let mut inside_class = false;
168 while index < chars.len() {
169 if let Some(code_point) = unicode_escape_at(&chars, index) {
170 if inside_class && is_surrogate(code_point) {
171 out.push_str(SURROGATE_PAIR_ASTRAL_RANGE);
172 index = end_of_surrogate_run(&chars, index);
173 } else {
174 out.push_str(&format!(r"\u{{{:04X}}}", nearest_scalar(code_point)));
175 index += UNICODE_ESCAPE_LEN;
176 }
177 continue;
178 }
179 let ch = chars[index];
180 match ch {
181 '\\' if index + 1 < chars.len() => {
182 out.push('\\');
183 out.push(chars[index + 1]);
184 index += 2;
185 continue;
186 }
187 '[' => inside_class = true,
188 ']' => inside_class = false,
189 _ => {}
190 }
191 out.push(ch);
192 index += 1;
193 }
194 out
195}
196
197const SURROGATE_PAIR_ASTRAL_RANGE: &str = r"\u{10000}-\u{10FFFF}";
199
200const UNICODE_ESCAPE_LEN: usize = 6;
202
203fn is_surrogate(code_point: u32) -> bool {
204 (0xD800..=0xDFFF).contains(&code_point)
205}
206
207fn nearest_scalar(code_point: u32) -> u32 {
212 if !is_surrogate(code_point) {
213 code_point
214 } else if code_point <= 0xDBFF {
215 0xD7FF
216 } else {
217 0xE000
218 }
219}
220
221fn unicode_escape_at(chars: &[char], index: usize) -> Option<u32> {
223 if chars.get(index) != Some(&'\\') || chars.get(index + 1) != Some(&'u') {
224 return None;
225 }
226 let hex: String = chars.get(index + 2..index + UNICODE_ESCAPE_LEN)?.iter().collect();
227 if !hex.chars().all(|digit| digit.is_ascii_hexdigit()) {
228 return None;
229 }
230 u32::from_str_radix(&hex, 16).ok()
231}
232
233fn end_of_surrogate_run(chars: &[char], mut index: usize) -> usize {
237 loop {
238 index += UNICODE_ESCAPE_LEN;
239 if chars.get(index) == Some(&'-') && unicode_escape_at(chars, index + 1).is_some_and(is_surrogate) {
240 index += 1;
241 } else if !unicode_escape_at(chars, index).is_some_and(is_surrogate) {
242 return index;
243 }
244 }
245}
246
247fn expand_posix_classes(pattern: &str) -> String {
254 const GRAPH_TOKEN: &[char] = &['\\', 'p', '{', 'G', 'r', 'a', 'p', 'h', '}'];
255 const PRINT_TOKEN: &[char] = &['\\', 'p', '{', 'P', 'r', 'i', 'n', 't', '}'];
256 const GRAPH_MEMBERS: &str = r"\p{L}\p{M}\p{N}\p{P}\p{S}\p{Cf}\p{Co}";
257 const PRINT_MEMBERS: &str = r"\p{L}\p{M}\p{N}\p{P}\p{S}\p{Cf}\p{Co}\p{Zs}\x20";
258 let chars: Vec<char> = pattern.chars().collect();
259 let mut out = String::with_capacity(pattern.len());
260 let mut index = 0;
261 let mut inside_class = false;
262 while index < chars.len() {
263 if let Some(replacement) = posix_expansion(&chars, index, GRAPH_TOKEN, GRAPH_MEMBERS, inside_class) {
264 out.push_str(&replacement);
265 index += GRAPH_TOKEN.len();
266 continue;
267 }
268 if let Some(replacement) = posix_expansion(&chars, index, PRINT_TOKEN, PRINT_MEMBERS, inside_class) {
269 out.push_str(&replacement);
270 index += PRINT_TOKEN.len();
271 continue;
272 }
273 let ch = chars[index];
274 match ch {
275 '\\' if index + 1 < chars.len() => {
276 out.push('\\');
277 out.push(chars[index + 1]);
278 index += 2;
279 continue;
280 }
281 '[' => inside_class = true,
282 ']' => inside_class = false,
283 _ => {}
284 }
285 out.push(ch);
286 index += 1;
287 }
288 out
289}
290
291fn posix_expansion(chars: &[char], index: usize, token: &[char], members: &str, inside_class: bool) -> Option<String> {
295 if !chars[index..].starts_with(token) {
296 return None;
297 }
298 Some(if inside_class { members.to_string() } else { format!("[{members}]") })
299}
300
301const UNICODE_OTHER_COMPLEMENT: &[char] = &['[', '^', '\\', 'p', '{', 'C', '}', ']'];
302const UNICODE_OTHER_LOOKAHEAD: &str = r"(?:(?!\p{C})(?s:.))";
303
304fn rewrite_unicode_other_complement(pattern: &str) -> String {
309 let chars: Vec<char> = pattern.chars().collect();
310 let mut out = String::with_capacity(pattern.len());
311 let mut index = 0;
312 let mut inside_class = false;
313 while index < chars.len() {
314 if chars[index] == '\\' && index + 1 < chars.len() {
315 out.push(chars[index]);
316 out.push(chars[index + 1]);
317 index += 2;
318 continue;
319 }
320 if !inside_class && chars[index..].starts_with(UNICODE_OTHER_COMPLEMENT) {
321 out.push_str(UNICODE_OTHER_LOOKAHEAD);
322 index += UNICODE_OTHER_COMPLEMENT.len();
323 continue;
324 }
325 match chars[index] {
326 '[' => inside_class = true,
327 ']' => inside_class = false,
328 _ => {}
329 }
330 out.push(chars[index]);
331 index += 1;
332 }
333 out
334}
335
336fn repair_char_classes(pattern: &str) -> String {
341 let chars: Vec<char> = pattern.chars().collect();
342 let mut out = String::with_capacity(pattern.len() + 8);
343 let mut index = 0;
344 let mut inside_class = false;
345 let mut prev_was_shorthand = false;
346 while index < chars.len() {
347 let ch = chars[index];
348 if ch == '\\' && index + 1 < chars.len() {
349 let next = chars[index + 1];
350 out.push('\\');
351 out.push(next);
352 if (next == 'p' || next == 'P') && index + 2 < chars.len() && chars[index + 2] == '{' {
353 let mut end = index + 2;
354 while end < chars.len() && chars[end] != '}' {
355 out.push(chars[end]);
356 end += 1;
357 }
358 if end < chars.len() {
359 out.push('}');
360 }
361 index = end + 1;
362 prev_was_shorthand = inside_class;
363 continue;
364 }
365 prev_was_shorthand = inside_class && matches!(next, 'w' | 'd' | 's' | 'W' | 'D' | 'S');
366 index += 2;
367 continue;
368 }
369 if ch == '[' {
370 if inside_class {
371 out.push_str(r"\[");
372 index += 1;
373 prev_was_shorthand = false;
374 continue;
375 }
376 inside_class = true;
377 prev_was_shorthand = false;
378 out.push('[');
379 index += 1;
380 if index < chars.len() && chars[index] == '^' {
381 out.push('^');
382 index += 1;
383 }
384 if index < chars.len() && chars[index] == ']' {
385 out.push(']');
386 index += 1;
387 }
388 continue;
389 }
390 if ch == ']' && inside_class {
391 inside_class = false;
392 out.push(']');
393 index += 1;
394 prev_was_shorthand = false;
395 continue;
396 }
397 if ch == '-' && inside_class && prev_was_shorthand {
398 out.push_str(r"\-");
399 index += 1;
400 prev_was_shorthand = false;
401 continue;
402 }
403 out.push(ch);
404 prev_was_shorthand = false;
405 index += 1;
406 }
407 out
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn plain_pattern_compiles_on_fast_engine() {
416 let compiled = compile(r"^ami-[0-9a-f]{8,17}$").expect("plain pattern compiles");
417 assert!(matches!(*compiled, CompiledPattern::Fast(_)));
418 assert!(compiled.is_match("ami-0123abcd"));
419 assert!(!compiled.is_match("vpc-0123abcd"));
420 }
421
422 #[test]
423 fn compile_is_memoized_and_returns_the_same_instance() {
424 let first = compile(r"^cache-test-[0-9]+$").expect("compiles");
425 let second = compile(r"^cache-test-[0-9]+$").expect("compiles");
426 assert!(Arc::ptr_eq(&first, &second), "repeated compile must reuse the cached instance");
427 }
428
429 #[test]
430 fn print_class_matches_format_and_private_use_characters() {
431 let compiled = compile(r"^\p{Print}+$").expect("\\p{Print} compiles");
434 assert!(compiled.is_match("normal text"));
435 assert!(compiled.is_match("zero\u{200B}width"), "U+200B (Cf) must match \\p{{Print}}");
436 assert!(compiled.is_match("private\u{E000}use"), "U+E000 (Co) must match \\p{{Print}}");
437 }
438
439 #[test]
440 fn negative_lookahead_compiles_and_matches_like_pcre() {
441 let compiled = compile(r"^(?!aws:).+$").expect("lookahead pattern compiles");
442 assert!(matches!(*compiled, CompiledPattern::Fancy(_)));
443 assert!(compiled.is_match("my-tag"));
444 assert!(!compiled.is_match("aws:reserved"));
445 }
446
447 #[test]
448 fn uppercase_z_anchor_is_normalized_to_lowercase() {
449 let compiled = compile(r"\A[0-9a-fA-F]+\Z").expect("\\A..\\Z pattern compiles");
450 assert!(compiled.is_match("abc123"));
451 assert!(!compiled.is_match("xyz"));
452 }
453
454 #[test]
455 fn size_limit_pattern_compiles_without_rewrite() {
456 let compiled = compile(r"^[\w.-]{1,255}$").expect("size-limit pattern compiles");
457 assert!(matches!(*compiled, CompiledPattern::Fast(_)));
458 assert!(compiled.is_match("valid.name-1"));
459 }
460
461 #[test]
462 fn bounded_unicode_other_complement_preserves_category_and_length_constraints() {
463 let compiled = compile(r"^[^\p{C}]{1,2048}$").expect("bounded Unicode category pattern compiles");
464
465 assert!(compiled.is_match("cafe\u{301} 123 ☃!"));
466 assert!(!compiled.is_match(""), "the lower length boundary must be enforced");
467 for (category, value) in [
468 ("Control", "line\nbreak"),
469 ("Format", "zero\u{200B}width"),
470 ("Private Use", "private\u{E000}use"),
471 ("Unassigned", "unassigned\u{0378}"),
472 ] {
473 assert!(!compiled.is_match(value), "Unicode {category} characters must be rejected");
474 }
475
476 let maximum_length = "a".repeat(2048);
477 let above_maximum_length = "a".repeat(2049);
478 assert!(compiled.is_match(&maximum_length), "the upper length boundary must be accepted");
479 assert!(!compiled.is_match(&above_maximum_length), "values above the upper length boundary must be rejected");
480 }
481
482 #[test]
483 fn print_class_still_matches_ordinary_text() {
484 let compiled = compile(r"^\p{Print}+$").expect("\\p{Print} pattern compiles");
485 for value in ["abc", "Hello World", "aws:reserved", "café", "a.b.c/x"] {
486 assert!(compiled.is_match(value), "\\p{{Print}} must match {value:?}");
487 }
488 assert!(!compiled.is_match(""));
489 }
490
491 #[test]
492 fn graph_class_inside_existing_class_compiles() {
493 let compiled = compile(r"^[\p{Graph}\x20]*$").expect("\\p{Graph} in class compiles");
494 assert!(compiled.is_match("visible text"));
495 }
496
497 #[test]
498 fn surrogate_pair_range_admits_astral_characters() {
499 let compiled = compile(r"^[ --�𐀀-\r\n\t]*$").expect("surrogate pattern compiles");
503 assert!(compiled.is_match("normal text"));
504 assert!(
505 compiled.is_match("emoji \u{1F600} and \u{10FFFF}"),
506 "astral characters must match the surrogate-pair range"
507 );
508 }
509
510 #[test]
511 fn low_then_high_surrogate_range_still_compiles() {
512 let compiled =
515 compile(r"^[ --�\uD800\uDBFF-\uDC00\uDFFF\t]*$").expect("reordered surrogate pattern compiles");
516 assert!(compiled.is_match("normal text"));
517 assert!(compiled.is_match("astral \u{1F4A9}"), "astral characters must match regardless of surrogate ordering");
518 }
519
520 #[test]
521 fn lone_surrogate_escape_outside_class_is_clamped_not_inverted() {
522 assert!(is_service_valid(r"^\uD800$"));
525 }
526
527 #[test]
528 fn hyphen_after_shorthand_is_treated_as_literal() {
529 let compiled = compile(r"[\w-.~]+").expect("hyphen-range pattern compiles");
530 assert!(compiled.is_match("a-b.c"));
531 }
532
533 #[test]
534 fn genuinely_malformed_pattern_is_not_service_valid() {
535 assert!(!is_service_valid(r"^(unbalanced"));
536 assert!(!is_service_valid(r"a{2,1}"));
537 }
538
539 #[test]
540 fn pcre_flavored_patterns_are_service_valid() {
541 assert!(is_service_valid(r"^(?!aws:).+$"));
542 assert!(is_service_valid(r"\A[0-9]+\Z"));
543 assert!(is_service_valid(r"^\p{Print}+$"));
544 assert!(is_service_valid(r"^[\w.-]{1,255}$"));
545 }
546
547 #[test]
548 fn normalize_preserves_plain_pattern_unchanged() {
549 assert_eq!(normalize(r"^[a-z0-9]+$"), r"^[a-z0-9]+$");
550 }
551
552 #[test]
553 fn anchor_allowed_pattern_adds_missing_anchors() {
554 assert_eq!(anchor_allowed_pattern("[a-z]+"), "^[a-z]+$");
555 assert_eq!(anchor_allowed_pattern("^[a-z]+"), "^[a-z]+$");
556 assert_eq!(anchor_allowed_pattern("[a-z]+$"), "^[a-z]+$");
557 assert_eq!(anchor_allowed_pattern("^[a-z]+$"), "^[a-z]+$");
558 }
559
560 #[test]
561 fn default_matches_pattern_scalar() {
562 assert_eq!(default_matches_pattern(r"[0-9]+", "123", false), Some(true));
563 assert_eq!(default_matches_pattern(r"[0-9]+", "12a", false), Some(false));
564 }
565
566 #[test]
567 fn default_matches_pattern_honors_pcre_syntax() {
568 assert_eq!(default_matches_pattern(r"\A[0-9a-fA-F]+\Z", "abc123", false), Some(true));
570 }
571
572 #[test]
573 fn default_matches_pattern_comma_delimited_checks_each_element() {
574 assert_eq!(default_matches_pattern(r"[a-z]+", "abc, def ,ghi", true), Some(true));
575 assert_eq!(default_matches_pattern(r"[a-z]+", "abc, DEF", true), Some(false));
576 }
577
578 #[test]
579 fn default_matches_pattern_returns_none_for_uncompilable() {
580 assert_eq!(default_matches_pattern(r"^(unbalanced", "x", false), None);
581 }
582}