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