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().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/// Whether a pattern is a valid regular expression that this tool can enforce - i.e. it either
93/// compiles directly or compiles after equivalence-preserving normalization / on the backtracking
94/// engine. A pattern that is genuinely malformed (unbalanced groups, dangling quantifier the source
95/// engine also rejects) returns `false`. Used by the rule that reports invalid `AllowedPattern`s so
96/// it never fires on a pattern that is merely PCRE-flavored rather than broken.
97#[must_use]
98pub fn is_service_valid(pattern: &str) -> bool {
99    compile(pattern).is_some()
100}
101
102/// Anchor an `AllowedPattern` the way CloudFormation does before matching: a pattern is implicitly
103/// wrapped so it must match the whole value. A leading `^` and/or trailing `$` already present are
104/// kept; missing anchors are added.
105#[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/// Whether every element of `value` matches `pattern` (auto-anchored). For a scalar parameter
116/// `value` is the whole default; for a `CommaDelimitedList`/`List<>` parameter it is the raw default
117/// and each comma-separated, trimmed element must match. Returns `None` when the pattern cannot be
118/// compiled at all - the caller reports that as an invalid pattern rather than a match failure.
119#[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
136/// Rewrite PCRE/Python constructs the Rust engines reject into an equivalent form, without ever
137/// narrowing the set of real (UTF-8) strings that match - narrowing would turn a passing template
138/// into a spurious violation, which is never acceptable.
139fn 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
148/// `\Z` in Python matches at end-of-string or just before a trailing newline; `\z` in Rust matches
149/// only at the absolute end. CloudFormation validates single-line scalar values that never carry a
150/// trailing newline, so the two are equivalent for every real input.
151///
152/// Convert `\uXXXX` (JSON/Python) escapes to the `regex` crate's `\u{XXXX}` brace form. A UTF-16
153/// surrogate code unit (`D800`–`DFFF`) is not a Unicode scalar value and cannot stand alone in a Rust
154/// `&str`; schemas emit them only as surrogate-pair ranges (the XML `Char` production), which denote
155/// the astral planes. A run of such escapes inside a character class is therefore collapsed to the
156/// single scalar range `\u{10000}-\u{10FFFF}` those pairs encode - this compiles and matches every
157/// astral character the pattern admits, whereas clamping each surrogate half to a BMP boundary would
158/// both drop the astral planes (a spurious violation) and, when the low half precedes the high half,
159/// invert the range into an uncompilable pattern.
160fn 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
194/// The scalar range a UTF-16 surrogate-pair range decodes to: the astral planes, U+10000–U+10FFFF.
195const SURROGATE_PAIR_ASTRAL_RANGE: &str = r"\u{10000}-\u{10FFFF}";
196
197/// Character length of a `\uXXXX` escape: backslash, `u`, four hex digits.
198const UNICODE_ESCAPE_LEN: usize = 6;
199
200fn is_surrogate(code_point: u32) -> bool {
201    (0xD800..=0xDFFF).contains(&code_point)
202}
203
204/// The escape's own code point, or - for a lone surrogate half that reaches the single-escape path
205/// rather than a surrogate-pair range - the nearest scalar boundary, since a surrogate cannot be
206/// emitted as a `\u{...}` scalar. Clamping a single half rather than dropping it keeps the class
207/// well-formed; a real surrogate-pair range is handled separately and never lands here.
208fn 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
218/// The code point of a `\uXXXX` escape starting at `index`, or `None` if no such escape is there.
219fn 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
230/// Index just past a maximal run of surrogate `\uXXXX` escapes beginning at `index`, consuming the
231/// `-` separators that join them into ranges (e.g. `𐀀-􏿿`). The whole run
232/// denotes one astral scalar range regardless of how the halves are grouped or ordered.
233fn 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
244/// POSIX-shorthand character classes that PCRE-style engines accept but Rust does not. Each is
245/// expanded to the exact set of Unicode general categories the class denotes, so no character the
246/// service would accept is rejected and none it rejects is admitted. `\p{Graph}` is every visible
247/// character - letters, marks, numbers, punctuation, symbols, plus format (`Cf`) and private-use
248/// (`Co`) code points; `\p{Print}` additionally allows the space separator (`Zs`). The replacement
249/// is emitted as class members so it works both bare and inside an existing `[...]`.
250fn 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
288/// If `token` (e.g. `\p{Graph}`) starts at `index` in `chars`, produce its expansion - wrapped in
289/// `[...]` when it appears bare so it stays a single matchable unit, or as raw members when already
290/// inside a character class.
291fn 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
301/// A large bounded repetition of `[^\p{C}]` expands beyond the `regex` crate's NFA size limit.
302/// Expressing the same one-scalar predicate as a lookahead keeps the repetition compact in the
303/// backtracking engine: the lookahead rejects Unicode Other and the dot-all atom consumes exactly
304/// one scalar from every remaining general category.
305fn 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
333/// Repair character classes that Rust rejects but which are valid (or intended) service-side:
334/// a `-` immediately after a shorthand (`\w`, `\d`, `\s`, `\p{...}`) or a `[` appearing literally
335/// inside a class. Escaping the hyphen makes it a literal `-` (its only sensible meaning there), and
336/// escaping the inner `[` makes it a literal `[`; neither changes which characters the class admits.
337fn 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        // `\p{Print}` includes format (Cf) and private-use (Co) code points; the expansion must not
429        // narrow them away or a valid value becomes a spurious violation.
430        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        // The XML `Char` production shipped in schemas encodes the astral planes as a UTF-16
497        // surrogate-pair range. Collapsing it to the scalar astral range must keep those characters
498        // matchable - dropping them would make a value the service accepts fail spuriously.
499        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        // The same class also ships with the surrogate halves ordered low-before-high; clamping each
510        // half independently would invert the range (E000..D7FF) and fail to compile.
511        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        // A `\uXXXX` escape outside a character class is a single code point, not a range endpoint;
520        // a surrogate there is clamped to the nearest scalar so the pattern still compiles.
521        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        // \A..\Z is valid service-side; "abc123" matches, so no violation should be reported.
566        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}