Skip to main content

template_model/
pattern.rs

1//! Shared regex compilation for CloudFormation `pattern`/`AllowedPattern` constraints.
2//!
3//! CloudFormation validates patterns with a PCRE-style engine that supports lookaround,
4//! backreferences, `\Z`, `\uXXXX` escapes, POSIX classes and possessive quantifiers. Rust's `regex`
5//! crate is RE2-style and rejects those, so a provider-supplied pattern that is perfectly valid
6//! service-side would fail to compile and - if the caller swallows the error - silently drop the
7//! constraint (a false negative) or be treated as non-matching (a false positive).
8//!
9//! [`compile`] closes that gap. It tries, cheapest first: the `regex` crate with a raised
10//! compiled-size limit, then a normalization pass that rewrites Rust-incompatible-but-equivalent
11//! syntax, then the `fancy-regex` backtracking engine on the raw and normalized forms. A final
12//! fallback rewrites oversized bounded-dot repetitions into an equivalent compact form for the
13//! backtracking engine. Every pattern shipped in the compiled schemas - and every service-valid
14//! `AllowedPattern` - compiles through one of these paths, so a constraint is never silently
15//! discarded. [`is_service_valid`] reports whether a pattern is enforceable this way, for the rule
16//! that flags genuinely-malformed `AllowedPattern`s.
17
18use std::collections::HashMap;
19use std::sync::{Arc, LazyLock, RwLock};
20
21/// Compiled-size ceiling for the `regex` crate. The crate's 10 MiB default rejects otherwise-simple
22/// patterns whose Unicode-aware shorthands (`\w`, `\p{...}`) are multiplied by a large bounded
23/// repetition (e.g. `^[\w.-]{1,255}$`); 64 MiB admits every schema pattern while still bounding a
24/// pathological input.
25const REGEX_SIZE_LIMIT_BYTES: usize = 64 << 20;
26
27/// Process-wide cache of compiled patterns, keyed by the raw pattern string. Compiling a regex (and
28/// especially the fancy-regex fallback) is far costlier than a map lookup, and the same schema/format
29/// patterns are validated against many resources and values, so each distinct pattern is compiled
30/// exactly once and the resulting [`CompiledPattern`] is shared thereafter. The `None` result for an
31/// uncompilable pattern is cached too, so a bad pattern is not re-attempted on every call.
32static PATTERN_CACHE: LazyLock<RwLock<HashMap<String, Option<Arc<CompiledPattern>>>>> =
33    LazyLock::new(|| RwLock::new(HashMap::new()));
34
35/// A regex compiled from a CloudFormation constraint, backed by whichever engine could compile it.
36///
37/// The `regex`-crate variant is used for the common RE2-compatible case; the `fancy-regex` variant
38/// handles lookaround/backreference patterns. Both expose a uniform [`CompiledPattern::is_match`].
39#[derive(Debug)]
40pub enum CompiledPattern {
41    Fast(regex::Regex),
42    Fancy(fancy_regex::Regex),
43}
44
45impl CompiledPattern {
46    /// Whether `text` matches the pattern. A `fancy-regex` match that errors at runtime (e.g. its
47    /// backtrack limit is hit on adversarial input) is reported as non-matching rather than
48    /// propagating, mirroring the crate's own fallible contract; such inputs are not real template
49    /// values and treating them as non-matching cannot mask a legitimate constraint violation.
50    #[must_use]
51    pub fn is_match(&self, text: &str) -> bool {
52        match self {
53            CompiledPattern::Fast(re) => re.is_match(text),
54            CompiledPattern::Fancy(re) => re.is_match(text).unwrap_or(false),
55        }
56    }
57}
58
59/// Compile a CloudFormation `pattern`/`AllowedPattern` string, preserving its matched language.
60///
61/// The result is memoized: each distinct pattern is compiled once and the shared [`CompiledPattern`]
62/// is returned on subsequent calls. Returns `None` only when the pattern cannot be made to compile by
63/// any strategy - which, for the schemas shipped in this crate's consumers, does not occur (enforced
64/// by a corpus test). Callers treat `None` as "constraint could not be enforced" and must not
65/// silently accept the value.
66#[must_use]
67pub fn compile(pattern: &str) -> Option<Arc<CompiledPattern>> {
68    if let Some(cached) = PATTERN_CACHE.read().unwrap_or_else(|poisoned| poisoned.into_inner()).get(pattern) {
69        return cached.clone();
70    }
71    let compiled = compile_uncached(pattern).map(Arc::new);
72    PATTERN_CACHE
73        .write()
74        .unwrap_or_else(|poisoned| poisoned.into_inner())
75        .insert(pattern.to_string(), compiled.clone());
76    compiled
77}
78
79fn compile_uncached(pattern: &str) -> Option<CompiledPattern> {
80    if let Ok(re) = build_fast(pattern) {
81        return Some(CompiledPattern::Fast(re));
82    }
83    let normalized = normalize(pattern);
84    let normalization_changed_pattern = normalized != pattern;
85    if normalization_changed_pattern && let Ok(re) = build_fast(&normalized) {
86        return Some(CompiledPattern::Fast(re));
87    }
88    if let Ok(re) = fancy_regex::Regex::new(pattern) {
89        return Some(CompiledPattern::Fancy(re));
90    }
91    if normalization_changed_pattern && let Ok(re) = fancy_regex::Regex::new(&normalized) {
92        return Some(CompiledPattern::Fancy(re));
93    }
94    let compact_bounded_dots = rewrite_bounded_dot_repetitions(&normalized);
95    if compact_bounded_dots != normalized
96        && let Ok(re) = fancy_regex::Regex::new(&compact_bounded_dots)
97    {
98        return Some(CompiledPattern::Fancy(re));
99    }
100    None
101}
102
103/// Whether a pattern is a valid regular expression that this tool can enforce - i.e. it either
104/// compiles directly or compiles after equivalence-preserving normalization / on the backtracking
105/// engine. A pattern that is genuinely malformed (unbalanced groups, dangling quantifier the source
106/// engine also rejects) returns `false`. Used by the rule that reports invalid `AllowedPattern`s so
107/// it never fires on a pattern that is merely PCRE-flavored rather than broken.
108#[must_use]
109pub fn is_service_valid(pattern: &str) -> bool {
110    compile(pattern).is_some()
111}
112
113/// Anchor an `AllowedPattern` the way CloudFormation does before matching: a pattern is implicitly
114/// wrapped so it must match the whole value. A leading `^` and/or trailing `$` already present are
115/// kept; missing anchors are added.
116#[must_use]
117pub fn anchor_allowed_pattern(pattern: &str) -> String {
118    match (pattern.starts_with('^'), pattern.ends_with('$')) {
119        (true, true) => pattern.to_string(),
120        (true, false) => format!("{pattern}$"),
121        (false, true) => format!("^{pattern}"),
122        (false, false) => format!("^{pattern}$"),
123    }
124}
125
126/// Whether every element of `value` matches `pattern` (auto-anchored). For a scalar parameter
127/// `value` is the whole default; for a `CommaDelimitedList`/`List<>` parameter it is the raw default
128/// and each comma-separated, trimmed element must match. Returns `None` when the pattern cannot be
129/// compiled at all - the caller reports that as an invalid pattern rather than a match failure.
130#[must_use]
131pub fn default_matches_pattern(pattern: &str, value: &str, is_comma_delimited: bool) -> Option<bool> {
132    let compiled = compile(&anchor_allowed_pattern(pattern))?;
133    if is_comma_delimited {
134        Some(value.split(',').all(|element| compiled.is_match(element.trim())))
135    } else {
136        Some(compiled.is_match(value))
137    }
138}
139
140fn build_fast(pattern: &str) -> Result<regex::Regex, regex::Error> {
141    regex::RegexBuilder::new(pattern).size_limit(REGEX_SIZE_LIMIT_BYTES).build()
142}
143
144static QUANTIFIED_LOOKAROUND: LazyLock<regex::Regex> =
145    LazyLock::new(|| regex::Regex::new(r"(\(\?[=!][^)]*\))[+*]").expect("QUANTIFIED_LOOKAROUND is a valid regex"));
146
147/// Rewrite PCRE/Python constructs the Rust engines reject into an equivalent form, without ever
148/// narrowing the set of real (UTF-8) strings that match - narrowing would turn a passing template
149/// into a spurious violation, which is never acceptable.
150fn normalize(pattern: &str) -> String {
151    let mut out = pattern.replace(r"\Z", r"\z");
152    out = convert_unicode_escapes(&out);
153    out = expand_posix_classes(&out);
154    out = rewrite_unicode_other_complement(&out);
155    out = repair_char_classes(&out);
156    QUANTIFIED_LOOKAROUND.replace_all(&out, "$1").into_owned()
157}
158
159const COMPACT_DOT_ATOM: &str = r"(?:(?=.)(?s:.))";
160
161/// Large bounded repetitions of `.` exceed the fast engine's automaton size limit. The lookahead
162/// evaluates the ordinary dot under its surrounding flags, while the dot-all atom consumes exactly
163/// one scalar and makes the backtracking engine retain the repetition as a compact counter. Only a
164/// single repetition is rewritten so this fallback cannot newly admit adjacent broad repetitions
165/// whose ambiguous partitions may exhaust the backtracking budget.
166fn rewrite_bounded_dot_repetitions(pattern: &str) -> String {
167    let chars: Vec<char> = pattern.chars().collect();
168    let mut out = String::with_capacity(pattern.len());
169    let mut index = 0;
170    let mut inside_class = false;
171    let mut has_rewritten_dot = false;
172    while index < chars.len() {
173        let character = chars[index];
174        if character == '\\' && index + 1 < chars.len() {
175            out.push(character);
176            out.push(chars[index + 1]);
177            index += 2;
178            continue;
179        }
180        if character == '[' && !inside_class {
181            inside_class = true;
182            out.push(character);
183            index += 1;
184            if chars.get(index) == Some(&'^') {
185                out.push('^');
186                index += 1;
187            }
188            if chars.get(index) == Some(&']') {
189                out.push(']');
190                index += 1;
191            }
192            continue;
193        }
194        if character == ']' && inside_class {
195            inside_class = false;
196        } else if character == '.' && !inside_class && bounded_quantifier_follows(&chars, index + 1) {
197            if has_rewritten_dot {
198                return pattern.to_string();
199            }
200            has_rewritten_dot = true;
201            out.push_str(COMPACT_DOT_ATOM);
202            index += 1;
203            continue;
204        }
205        out.push(character);
206        index += 1;
207    }
208    out
209}
210
211fn bounded_quantifier_follows(chars: &[char], index: usize) -> bool {
212    if chars.get(index) != Some(&'{') {
213        return false;
214    }
215
216    let mut cursor = index + 1;
217    let Some(minimum) = parse_quantifier_bound(chars, &mut cursor) else {
218        return false;
219    };
220
221    match chars.get(cursor) {
222        Some('}') => true,
223        Some(',') => {
224            cursor += 1;
225            parse_quantifier_bound(chars, &mut cursor)
226                .is_some_and(|maximum| minimum <= maximum && chars.get(cursor) == Some(&'}'))
227        }
228        _ => false,
229    }
230}
231
232fn parse_quantifier_bound(chars: &[char], cursor: &mut usize) -> Option<u64> {
233    let start = *cursor;
234    let mut bound = 0_u64;
235    while let Some(character) = chars.get(*cursor).filter(|character| character.is_ascii_digit()) {
236        bound = bound.checked_mul(10)?.checked_add(u64::from(character.to_digit(10)?))?;
237        *cursor += 1;
238    }
239    (*cursor > start).then_some(bound)
240}
241
242/// `\Z` in Python matches at end-of-string or just before a trailing newline; `\z` in Rust matches
243/// only at the absolute end. CloudFormation validates single-line scalar values that never carry a
244/// trailing newline, so the two are equivalent for every real input.
245///
246/// Convert `\uXXXX` (JSON/Python) escapes to the `regex` crate's `\u{XXXX}` brace form. A UTF-16
247/// surrogate code unit (`D800`–`DFFF`) is not a Unicode scalar value and cannot stand alone in a Rust
248/// `&str`; schemas emit them only as surrogate-pair ranges (the XML `Char` production), which denote
249/// the astral planes. A run of such escapes inside a character class is therefore collapsed to the
250/// single scalar range `\u{10000}-\u{10FFFF}` those pairs encode - this compiles and matches every
251/// astral character the pattern admits, whereas clamping each surrogate half to a BMP boundary would
252/// both drop the astral planes (a spurious violation) and, when the low half precedes the high half,
253/// invert the range into an uncompilable pattern.
254fn convert_unicode_escapes(pattern: &str) -> String {
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(code_point) = unicode_escape_at(&chars, index) {
261            if inside_class && is_surrogate(code_point) {
262                out.push_str(SURROGATE_PAIR_ASTRAL_RANGE);
263                index = end_of_surrogate_run(&chars, index);
264            } else {
265                out.push_str(&format!(r"\u{{{:04X}}}", nearest_scalar(code_point)));
266                index += UNICODE_ESCAPE_LEN;
267            }
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
288/// The scalar range a UTF-16 surrogate-pair range decodes to: the astral planes, U+10000–U+10FFFF.
289const SURROGATE_PAIR_ASTRAL_RANGE: &str = r"\u{10000}-\u{10FFFF}";
290
291/// Character length of a `\uXXXX` escape: backslash, `u`, four hex digits.
292const UNICODE_ESCAPE_LEN: usize = 6;
293
294fn is_surrogate(code_point: u32) -> bool {
295    (0xD800..=0xDFFF).contains(&code_point)
296}
297
298/// The escape's own code point, or - for a lone surrogate half that reaches the single-escape path
299/// rather than a surrogate-pair range - the nearest scalar boundary, since a surrogate cannot be
300/// emitted as a `\u{...}` scalar. Clamping a single half rather than dropping it keeps the class
301/// well-formed; a real surrogate-pair range is handled separately and never lands here.
302fn nearest_scalar(code_point: u32) -> u32 {
303    if !is_surrogate(code_point) {
304        code_point
305    } else if code_point <= 0xDBFF {
306        0xD7FF
307    } else {
308        0xE000
309    }
310}
311
312/// The code point of a `\uXXXX` escape starting at `index`, or `None` if no such escape is there.
313fn unicode_escape_at(chars: &[char], index: usize) -> Option<u32> {
314    if chars.get(index) != Some(&'\\') || chars.get(index + 1) != Some(&'u') {
315        return None;
316    }
317    let hex: String = chars.get(index + 2..index + UNICODE_ESCAPE_LEN)?.iter().collect();
318    if !hex.chars().all(|digit| digit.is_ascii_hexdigit()) {
319        return None;
320    }
321    u32::from_str_radix(&hex, 16).ok()
322}
323
324/// Index just past a maximal run of surrogate `\uXXXX` escapes beginning at `index`, consuming the
325/// `-` separators that join them into ranges (e.g. `𐀀-􏿿`). The whole run
326/// denotes one astral scalar range regardless of how the halves are grouped or ordered.
327fn end_of_surrogate_run(chars: &[char], mut index: usize) -> usize {
328    loop {
329        index += UNICODE_ESCAPE_LEN;
330        if chars.get(index) == Some(&'-') && unicode_escape_at(chars, index + 1).is_some_and(is_surrogate) {
331            index += 1;
332        } else if !unicode_escape_at(chars, index).is_some_and(is_surrogate) {
333            return index;
334        }
335    }
336}
337
338/// POSIX-shorthand character classes that PCRE-style engines accept but Rust does not. Each is
339/// expanded to the exact set of Unicode general categories the class denotes, so no character the
340/// service would accept is rejected and none it rejects is admitted. `\p{Graph}` is every visible
341/// character - letters, marks, numbers, punctuation, symbols, plus format (`Cf`) and private-use
342/// (`Co`) code points; `\p{Print}` additionally allows the space separator (`Zs`). The replacement
343/// is emitted as class members so it works both bare and inside an existing `[...]`.
344fn expand_posix_classes(pattern: &str) -> String {
345    const GRAPH_TOKEN: &[char] = &['\\', 'p', '{', 'G', 'r', 'a', 'p', 'h', '}'];
346    const PRINT_TOKEN: &[char] = &['\\', 'p', '{', 'P', 'r', 'i', 'n', 't', '}'];
347    const GRAPH_MEMBERS: &str = r"\p{L}\p{M}\p{N}\p{P}\p{S}\p{Cf}\p{Co}";
348    const PRINT_MEMBERS: &str = r"\p{L}\p{M}\p{N}\p{P}\p{S}\p{Cf}\p{Co}\p{Zs}\x20";
349    let chars: Vec<char> = pattern.chars().collect();
350    let mut out = String::with_capacity(pattern.len());
351    let mut index = 0;
352    let mut inside_class = false;
353    while index < chars.len() {
354        if let Some(replacement) = posix_expansion(&chars, index, GRAPH_TOKEN, GRAPH_MEMBERS, inside_class) {
355            out.push_str(&replacement);
356            index += GRAPH_TOKEN.len();
357            continue;
358        }
359        if let Some(replacement) = posix_expansion(&chars, index, PRINT_TOKEN, PRINT_MEMBERS, inside_class) {
360            out.push_str(&replacement);
361            index += PRINT_TOKEN.len();
362            continue;
363        }
364        let ch = chars[index];
365        match ch {
366            '\\' if index + 1 < chars.len() => {
367                out.push('\\');
368                out.push(chars[index + 1]);
369                index += 2;
370                continue;
371            }
372            '[' => inside_class = true,
373            ']' => inside_class = false,
374            _ => {}
375        }
376        out.push(ch);
377        index += 1;
378    }
379    out
380}
381
382/// If `token` (e.g. `\p{Graph}`) starts at `index` in `chars`, produce its expansion - wrapped in
383/// `[...]` when it appears bare so it stays a single matchable unit, or as raw members when already
384/// inside a character class.
385fn posix_expansion(chars: &[char], index: usize, token: &[char], members: &str, inside_class: bool) -> Option<String> {
386    if !chars[index..].starts_with(token) {
387        return None;
388    }
389    Some(if inside_class { members.to_string() } else { format!("[{members}]") })
390}
391
392const UNICODE_OTHER_COMPLEMENT: &[char] = &['[', '^', '\\', 'p', '{', 'C', '}', ']'];
393const UNICODE_OTHER_LOOKAHEAD: &str = r"(?:(?!\p{C})(?s:.))";
394
395/// A large bounded repetition of `[^\p{C}]` expands beyond the `regex` crate's NFA size limit.
396/// Expressing the same one-scalar predicate as a lookahead keeps the repetition compact in the
397/// backtracking engine: the lookahead rejects Unicode Other and the dot-all atom consumes exactly
398/// one scalar from every remaining general category.
399fn rewrite_unicode_other_complement(pattern: &str) -> String {
400    let chars: Vec<char> = pattern.chars().collect();
401    let mut out = String::with_capacity(pattern.len());
402    let mut index = 0;
403    let mut inside_class = false;
404    while index < chars.len() {
405        if chars[index] == '\\' && index + 1 < chars.len() {
406            out.push(chars[index]);
407            out.push(chars[index + 1]);
408            index += 2;
409            continue;
410        }
411        if !inside_class && chars[index..].starts_with(UNICODE_OTHER_COMPLEMENT) {
412            out.push_str(UNICODE_OTHER_LOOKAHEAD);
413            index += UNICODE_OTHER_COMPLEMENT.len();
414            continue;
415        }
416        match chars[index] {
417            '[' => inside_class = true,
418            ']' => inside_class = false,
419            _ => {}
420        }
421        out.push(chars[index]);
422        index += 1;
423    }
424    out
425}
426
427/// Repair character classes that Rust rejects but which are valid (or intended) service-side:
428/// a `-` immediately after a shorthand (`\w`, `\d`, `\s`, `\p{...}`) or a `[` appearing literally
429/// inside a class. Escaping the hyphen makes it a literal `-` (its only sensible meaning there), and
430/// escaping the inner `[` makes it a literal `[`; neither changes which characters the class admits.
431fn repair_char_classes(pattern: &str) -> String {
432    let chars: Vec<char> = pattern.chars().collect();
433    let mut out = String::with_capacity(pattern.len() + 8);
434    let mut index = 0;
435    let mut inside_class = false;
436    let mut prev_was_shorthand = false;
437    while index < chars.len() {
438        let ch = chars[index];
439        if ch == '\\' && index + 1 < chars.len() {
440            let next = chars[index + 1];
441            out.push('\\');
442            out.push(next);
443            if (next == 'p' || next == 'P') && index + 2 < chars.len() && chars[index + 2] == '{' {
444                let mut end = index + 2;
445                while end < chars.len() && chars[end] != '}' {
446                    out.push(chars[end]);
447                    end += 1;
448                }
449                if end < chars.len() {
450                    out.push('}');
451                }
452                index = end + 1;
453                prev_was_shorthand = inside_class;
454                continue;
455            }
456            prev_was_shorthand = inside_class && matches!(next, 'w' | 'd' | 's' | 'W' | 'D' | 'S');
457            index += 2;
458            continue;
459        }
460        if ch == '[' {
461            if inside_class {
462                out.push_str(r"\[");
463                index += 1;
464                prev_was_shorthand = false;
465                continue;
466            }
467            inside_class = true;
468            prev_was_shorthand = false;
469            out.push('[');
470            index += 1;
471            if index < chars.len() && chars[index] == '^' {
472                out.push('^');
473                index += 1;
474            }
475            if index < chars.len() && chars[index] == ']' {
476                out.push(']');
477                index += 1;
478            }
479            continue;
480        }
481        if ch == ']' && inside_class {
482            inside_class = false;
483            out.push(']');
484            index += 1;
485            prev_was_shorthand = false;
486            continue;
487        }
488        if ch == '-' && inside_class && prev_was_shorthand {
489            out.push_str(r"\-");
490            index += 1;
491            prev_was_shorthand = false;
492            continue;
493        }
494        out.push(ch);
495        prev_was_shorthand = false;
496        index += 1;
497    }
498    out
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn plain_pattern_compiles_on_fast_engine() {
507        let compiled = compile(r"^ami-[0-9a-f]{8,17}$").expect("plain pattern compiles");
508        assert!(matches!(*compiled, CompiledPattern::Fast(_)));
509        assert!(compiled.is_match("ami-0123abcd"));
510        assert!(!compiled.is_match("vpc-0123abcd"));
511    }
512
513    #[test]
514    fn compile_is_memoized_and_returns_the_same_instance() {
515        let first = compile(r"^cache-test-[0-9]+$").expect("compiles");
516        let second = compile(r"^cache-test-[0-9]+$").expect("compiles");
517        assert!(Arc::ptr_eq(&first, &second), "repeated compile must reuse the cached instance");
518    }
519
520    #[test]
521    fn print_class_matches_format_and_private_use_characters() {
522        // `\p{Print}` includes format (Cf) and private-use (Co) code points; the expansion must not
523        // narrow them away or a valid value becomes a spurious violation.
524        let compiled = compile(r"^\p{Print}+$").expect("\\p{Print} compiles");
525        assert!(compiled.is_match("normal text"));
526        assert!(compiled.is_match("zero\u{200B}width"), "U+200B (Cf) must match \\p{{Print}}");
527        assert!(compiled.is_match("private\u{E000}use"), "U+E000 (Co) must match \\p{{Print}}");
528    }
529
530    #[test]
531    fn negative_lookahead_compiles_and_matches_like_pcre() {
532        let compiled = compile(r"^(?!aws:).+$").expect("lookahead pattern compiles");
533        assert!(matches!(*compiled, CompiledPattern::Fancy(_)));
534        assert!(compiled.is_match("my-tag"));
535        assert!(!compiled.is_match("aws:reserved"));
536    }
537
538    #[test]
539    fn uppercase_z_anchor_is_normalized_to_lowercase() {
540        let compiled = compile(r"\A[0-9a-fA-F]+\Z").expect("\\A..\\Z pattern compiles");
541        assert!(compiled.is_match("abc123"));
542        assert!(!compiled.is_match("xyz"));
543    }
544
545    #[test]
546    fn size_limit_pattern_compiles_without_rewrite() {
547        let compiled = compile(r"^[\w.-]{1,255}$").expect("size-limit pattern compiles");
548        assert!(matches!(*compiled, CompiledPattern::Fast(_)));
549        assert!(compiled.is_match("valid.name-1"));
550    }
551
552    #[test]
553    fn bounded_dot_pattern_uses_other_normalizations_before_the_compact_fallback() {
554        let compiled = compile(r"\A.{1,64}\Z").expect("normalized bounded dot pattern compiles");
555        assert!(matches!(*compiled, CompiledPattern::Fast(_)));
556        assert!(!compiled.is_match("line\n"));
557        assert_eq!(normalize(r"^(?=.{1,5})+abc$"), r"^(?=.{1,5})abc$");
558    }
559
560    #[test]
561    fn large_bounded_dot_repetition_preserves_length_anchors_and_dot_semantics() {
562        const MAXIMUM_LENGTH: usize = 65_535;
563
564        let compiled = compile(r"^.{1,65535}$").expect("large bounded dot repetition compiles");
565        let CompiledPattern::Fancy(regex) = compiled.as_ref() else {
566            panic!("large bounded dot repetition must use the compact backtracking representation");
567        };
568        assert!(matches!(regex.is_match("é"), Ok(true)), "one Unicode scalar must count as one character");
569        assert!(matches!(regex.is_match(""), Ok(false)), "the lower length boundary must be enforced");
570        assert!(
571            matches!(regex.is_match("line\nbreak"), Ok(false)),
572            "the compact atom must preserve ordinary dot behavior"
573        );
574        assert!(
575            matches!(regex.is_match("line\n"), Ok(false)),
576            "the compact representation must preserve the engine's anchor behavior"
577        );
578
579        let maximum_length = "a".repeat(MAXIMUM_LENGTH);
580        let above_maximum_length = "a".repeat(MAXIMUM_LENGTH + 1);
581        assert!(matches!(regex.is_match(&maximum_length), Ok(true)), "the upper length boundary must be accepted");
582        assert!(
583            matches!(regex.is_match(&above_maximum_length), Ok(false)),
584            "values above the upper boundary must be rejected without exhausting the backtracking budget"
585        );
586
587        let dot_all = compile(r"(?s:^.{1,65535}$)").expect("dot-all bounded repetition compiles");
588        let dot_all_matches_line_feed = match dot_all.as_ref() {
589            CompiledPattern::Fast(regex) => regex.is_match("\n"),
590            CompiledPattern::Fancy(regex) => matches!(regex.is_match("\n"), Ok(true)),
591        };
592        assert!(dot_all_matches_line_feed, "the compiled pattern must preserve surrounding dot-all behavior");
593    }
594
595    #[test]
596    fn bounded_dot_rewrite_ignores_escaped_dots_and_character_classes() {
597        assert_eq!(
598            rewrite_bounded_dot_repetitions(r"^.{1,65535}\.{1,65535}[.]{1,65535}$"),
599            r"^(?:(?=.)(?s:.)){1,65535}\.{1,65535}[.]{1,65535}$"
600        );
601        for character_class_pattern in [r"^[]a.{1,2}]$", r"^[^]a.{1,2}]$"] {
602            assert_eq!(rewrite_bounded_dot_repetitions(character_class_pattern), character_class_pattern);
603        }
604
605        let adjacent_broad_repetitions = r"^.{1,30000}x.{1,30000}$";
606        assert_eq!(
607            rewrite_bounded_dot_repetitions(adjacent_broad_repetitions),
608            adjacent_broad_repetitions,
609            "the fallback must not admit ambiguous adjacent broad repetitions"
610        );
611    }
612
613    #[test]
614    fn bounded_unicode_other_complement_preserves_category_and_length_constraints() {
615        let compiled = compile(r"^[^\p{C}]{1,2048}$").expect("bounded Unicode category pattern compiles");
616
617        assert!(compiled.is_match("cafe\u{301} 123 ☃!"));
618        assert!(!compiled.is_match(""), "the lower length boundary must be enforced");
619        for (category, value) in [
620            ("Control", "line\nbreak"),
621            ("Format", "zero\u{200B}width"),
622            ("Private Use", "private\u{E000}use"),
623            ("Unassigned", "unassigned\u{0378}"),
624        ] {
625            assert!(!compiled.is_match(value), "Unicode {category} characters must be rejected");
626        }
627
628        let maximum_length = "a".repeat(2048);
629        let above_maximum_length = "a".repeat(2049);
630        assert!(compiled.is_match(&maximum_length), "the upper length boundary must be accepted");
631        assert!(!compiled.is_match(&above_maximum_length), "values above the upper length boundary must be rejected");
632    }
633
634    #[test]
635    fn print_class_still_matches_ordinary_text() {
636        let compiled = compile(r"^\p{Print}+$").expect("\\p{Print} pattern compiles");
637        for value in ["abc", "Hello World", "aws:reserved", "café", "a.b.c/x"] {
638            assert!(compiled.is_match(value), "\\p{{Print}} must match {value:?}");
639        }
640        assert!(!compiled.is_match(""));
641    }
642
643    #[test]
644    fn graph_class_inside_existing_class_compiles() {
645        let compiled = compile(r"^[\p{Graph}\x20]*$").expect("\\p{Graph} in class compiles");
646        assert!(compiled.is_match("visible text"));
647    }
648
649    #[test]
650    fn surrogate_pair_range_admits_astral_characters() {
651        // The XML `Char` production shipped in schemas encodes the astral planes as a UTF-16
652        // surrogate-pair range. Collapsing it to the scalar astral range must keep those characters
653        // matchable - dropping them would make a value the service accepts fail spuriously.
654        let compiled = compile(r"^[ -퟿-�𐀀-􏿿\r\n\t]*$").expect("surrogate pattern compiles");
655        assert!(compiled.is_match("normal text"));
656        assert!(
657            compiled.is_match("emoji \u{1F600} and \u{10FFFF}"),
658            "astral characters must match the surrogate-pair range"
659        );
660    }
661
662    #[test]
663    fn low_then_high_surrogate_range_still_compiles() {
664        // The same class also ships with the surrogate halves ordered low-before-high; clamping each
665        // half independently would invert the range (E000..D7FF) and fail to compile.
666        let compiled =
667            compile(r"^[ -퟿-�\uD800\uDBFF-\uDC00\uDFFF\t]*$").expect("reordered surrogate pattern compiles");
668        assert!(compiled.is_match("normal text"));
669        assert!(compiled.is_match("astral \u{1F4A9}"), "astral characters must match regardless of surrogate ordering");
670    }
671
672    #[test]
673    fn lone_surrogate_escape_outside_class_is_clamped_not_inverted() {
674        // A `\uXXXX` escape outside a character class is a single code point, not a range endpoint;
675        // a surrogate there is clamped to the nearest scalar so the pattern still compiles.
676        assert!(is_service_valid(r"^\uD800$"));
677    }
678
679    #[test]
680    fn hyphen_after_shorthand_is_treated_as_literal() {
681        let compiled = compile(r"[\w-.~]+").expect("hyphen-range pattern compiles");
682        assert!(compiled.is_match("a-b.c"));
683    }
684
685    #[test]
686    fn genuinely_malformed_pattern_is_not_service_valid() {
687        assert!(!is_service_valid(r"^(unbalanced"));
688        assert!(!is_service_valid(r"a{2,1}"));
689        assert!(!is_service_valid(r"^.{2,1}$"));
690    }
691
692    #[test]
693    fn pcre_flavored_patterns_are_service_valid() {
694        assert!(is_service_valid(r"^(?!aws:).+$"));
695        assert!(is_service_valid(r"\A[0-9]+\Z"));
696        assert!(is_service_valid(r"^\p{Print}+$"));
697        assert!(is_service_valid(r"^[\w.-]{1,255}$"));
698    }
699
700    #[test]
701    fn normalize_preserves_plain_pattern_unchanged() {
702        assert_eq!(normalize(r"^[a-z0-9]+$"), r"^[a-z0-9]+$");
703    }
704
705    #[test]
706    fn anchor_allowed_pattern_adds_missing_anchors() {
707        assert_eq!(anchor_allowed_pattern("[a-z]+"), "^[a-z]+$");
708        assert_eq!(anchor_allowed_pattern("^[a-z]+"), "^[a-z]+$");
709        assert_eq!(anchor_allowed_pattern("[a-z]+$"), "^[a-z]+$");
710        assert_eq!(anchor_allowed_pattern("^[a-z]+$"), "^[a-z]+$");
711    }
712
713    #[test]
714    fn default_matches_pattern_scalar() {
715        assert_eq!(default_matches_pattern(r"[0-9]+", "123", false), Some(true));
716        assert_eq!(default_matches_pattern(r"[0-9]+", "12a", false), Some(false));
717    }
718
719    #[test]
720    fn default_matches_pattern_honors_pcre_syntax() {
721        // \A..\Z is valid service-side; "abc123" matches, so no violation should be reported.
722        assert_eq!(default_matches_pattern(r"\A[0-9a-fA-F]+\Z", "abc123", false), Some(true));
723    }
724
725    #[test]
726    fn default_matches_pattern_comma_delimited_checks_each_element() {
727        assert_eq!(default_matches_pattern(r"[a-z]+", "abc, def ,ghi", true), Some(true));
728        assert_eq!(default_matches_pattern(r"[a-z]+", "abc, DEF", true), Some(false));
729    }
730
731    #[test]
732    fn default_matches_pattern_returns_none_for_uncompilable() {
733        assert_eq!(default_matches_pattern(r"^(unbalanced", "x", false), None);
734    }
735}