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 (which understands lookaround/backreferences)
12//! on the raw and normalized forms. Every pattern shipped in the compiled schemas - and every
13//! service-valid `AllowedPattern` - compiles through one of these paths, so a constraint is never
14//! silently discarded. [`is_service_valid`] reports whether a pattern is enforceable this way, for
15//! the rule that flags genuinely-malformed `AllowedPattern`s.
16
17use std::collections::HashMap;
18use std::sync::{Arc, LazyLock, RwLock};
19
20/// Compiled-size ceiling for the `regex` crate. The crate's 10 MiB default rejects otherwise-simple
21/// patterns whose Unicode-aware shorthands (`\w`, `\p{...}`) are multiplied by a large bounded
22/// repetition (e.g. `^[\w.-]{1,255}$`); 64 MiB admits every schema pattern while still bounding a
23/// pathological input.
24const REGEX_SIZE_LIMIT_BYTES: usize = 64 << 20;
25
26/// Process-wide cache of compiled patterns, keyed by the raw pattern string. Compiling a regex (and
27/// especially the fancy-regex fallback) is far costlier than a map lookup, and the same schema/format
28/// patterns are validated against many resources and values, so each distinct pattern is compiled
29/// exactly once and the resulting [`CompiledPattern`] is shared thereafter. The `None` result for an
30/// uncompilable pattern is cached too, so a bad pattern is not re-attempted on every call.
31static PATTERN_CACHE: LazyLock<RwLock<HashMap<String, Option<Arc<CompiledPattern>>>>> =
32    LazyLock::new(|| RwLock::new(HashMap::new()));
33
34/// A regex compiled from a CloudFormation constraint, backed by whichever engine could compile it.
35///
36/// The `regex`-crate variant is used for the common RE2-compatible case; the `fancy-regex` variant
37/// handles lookaround/backreference patterns. Both expose a uniform [`CompiledPattern::is_match`].
38#[derive(Debug)]
39pub enum CompiledPattern {
40    Fast(regex::Regex),
41    Fancy(fancy_regex::Regex),
42}
43
44impl CompiledPattern {
45    /// Whether `text` matches the pattern. A `fancy-regex` match that errors at runtime (e.g. its
46    /// backtrack limit is hit on adversarial input) is reported as non-matching rather than
47    /// propagating, mirroring the crate's own fallible contract; such inputs are not real template
48    /// values and treating them as non-matching cannot mask a legitimate constraint violation.
49    #[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/// Compile a CloudFormation `pattern`/`AllowedPattern` string, preserving its matched language.
59///
60/// The result is memoized: each distinct pattern is compiled once and the shared [`CompiledPattern`]
61/// is returned on subsequent calls. Returns `None` only when the pattern cannot be made to compile by
62/// any strategy - which, for the schemas shipped in this crate's consumers, does not occur (enforced
63/// by a corpus test). Callers treat `None` as "constraint could not be enforced" and must not
64/// silently accept the value.
65#[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/// Whether a pattern is a valid regular expression that this tool can enforce - i.e. it either
96/// compiles directly or compiles after equivalence-preserving normalization / on the backtracking
97/// engine. A pattern that is genuinely malformed (unbalanced groups, dangling quantifier the source
98/// engine also rejects) returns `false`. Used by the rule that reports invalid `AllowedPattern`s so
99/// it never fires on a pattern that is merely PCRE-flavored rather than broken.
100#[must_use]
101pub fn is_service_valid(pattern: &str) -> bool {
102    compile(pattern).is_some()
103}
104
105/// Anchor an `AllowedPattern` the way CloudFormation does before matching: a pattern is implicitly
106/// wrapped so it must match the whole value. A leading `^` and/or trailing `$` already present are
107/// kept; missing anchors are added.
108#[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/// Whether every element of `value` matches `pattern` (auto-anchored). For a scalar parameter
119/// `value` is the whole default; for a `CommaDelimitedList`/`List<>` parameter it is the raw default
120/// and each comma-separated, trimmed element must match. Returns `None` when the pattern cannot be
121/// compiled at all - the caller reports that as an invalid pattern rather than a match failure.
122#[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
139/// Rewrite PCRE/Python constructs the Rust engines reject into an equivalent form, without ever
140/// narrowing the set of real (UTF-8) strings that match - narrowing would turn a passing template
141/// into a spurious violation, which is never acceptable.
142fn 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
151/// `\Z` in Python matches at end-of-string or just before a trailing newline; `\z` in Rust matches
152/// only at the absolute end. CloudFormation validates single-line scalar values that never carry a
153/// trailing newline, so the two are equivalent for every real input.
154///
155/// Convert `\uXXXX` (JSON/Python) escapes to the `regex` crate's `\u{XXXX}` brace form. A UTF-16
156/// surrogate code unit (`D800`–`DFFF`) is not a Unicode scalar value and cannot stand alone in a Rust
157/// `&str`; schemas emit them only as surrogate-pair ranges (the XML `Char` production), which denote
158/// the astral planes. A run of such escapes inside a character class is therefore collapsed to the
159/// single scalar range `\u{10000}-\u{10FFFF}` those pairs encode - this compiles and matches every
160/// astral character the pattern admits, whereas clamping each surrogate half to a BMP boundary would
161/// both drop the astral planes (a spurious violation) and, when the low half precedes the high half,
162/// invert the range into an uncompilable pattern.
163fn 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
197/// The scalar range a UTF-16 surrogate-pair range decodes to: the astral planes, U+10000–U+10FFFF.
198const SURROGATE_PAIR_ASTRAL_RANGE: &str = r"\u{10000}-\u{10FFFF}";
199
200/// Character length of a `\uXXXX` escape: backslash, `u`, four hex digits.
201const UNICODE_ESCAPE_LEN: usize = 6;
202
203fn is_surrogate(code_point: u32) -> bool {
204    (0xD800..=0xDFFF).contains(&code_point)
205}
206
207/// The escape's own code point, or - for a lone surrogate half that reaches the single-escape path
208/// rather than a surrogate-pair range - the nearest scalar boundary, since a surrogate cannot be
209/// emitted as a `\u{...}` scalar. Clamping a single half rather than dropping it keeps the class
210/// well-formed; a real surrogate-pair range is handled separately and never lands here.
211fn 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
221/// The code point of a `\uXXXX` escape starting at `index`, or `None` if no such escape is there.
222fn 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
233/// Index just past a maximal run of surrogate `\uXXXX` escapes beginning at `index`, consuming the
234/// `-` separators that join them into ranges (e.g. `𐀀-􏿿`). The whole run
235/// denotes one astral scalar range regardless of how the halves are grouped or ordered.
236fn 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
247/// POSIX-shorthand character classes that PCRE-style engines accept but Rust does not. Each is
248/// expanded to the exact set of Unicode general categories the class denotes, so no character the
249/// service would accept is rejected and none it rejects is admitted. `\p{Graph}` is every visible
250/// character - letters, marks, numbers, punctuation, symbols, plus format (`Cf`) and private-use
251/// (`Co`) code points; `\p{Print}` additionally allows the space separator (`Zs`). The replacement
252/// is emitted as class members so it works both bare and inside an existing `[...]`.
253fn 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
291/// If `token` (e.g. `\p{Graph}`) starts at `index` in `chars`, produce its expansion - wrapped in
292/// `[...]` when it appears bare so it stays a single matchable unit, or as raw members when already
293/// inside a character class.
294fn 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
304/// A large bounded repetition of `[^\p{C}]` expands beyond the `regex` crate's NFA size limit.
305/// Expressing the same one-scalar predicate as a lookahead keeps the repetition compact in the
306/// backtracking engine: the lookahead rejects Unicode Other and the dot-all atom consumes exactly
307/// one scalar from every remaining general category.
308fn 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
336/// Repair character classes that Rust rejects but which are valid (or intended) service-side:
337/// a `-` immediately after a shorthand (`\w`, `\d`, `\s`, `\p{...}`) or a `[` appearing literally
338/// inside a class. Escaping the hyphen makes it a literal `-` (its only sensible meaning there), and
339/// escaping the inner `[` makes it a literal `[`; neither changes which characters the class admits.
340fn 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        // `\p{Print}` includes format (Cf) and private-use (Co) code points; the expansion must not
432        // narrow them away or a valid value becomes a spurious violation.
433        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        // The XML `Char` production shipped in schemas encodes the astral planes as a UTF-16
500        // surrogate-pair range. Collapsing it to the scalar astral range must keep those characters
501        // matchable - dropping them would make a value the service accepts fail spuriously.
502        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        // The same class also ships with the surrogate halves ordered low-before-high; clamping each
513        // half independently would invert the range (E000..D7FF) and fail to compile.
514        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        // A `\uXXXX` escape outside a character class is a single code point, not a range endpoint;
523        // a surrogate there is clamped to the nearest scalar so the pattern still compiles.
524        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        // \A..\Z is valid service-side; "abc123" matches, so no violation should be reported.
569        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}