Skip to main content

mz_repr/adt/
regex.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Regular expressions.
11
12use std::borrow::Cow;
13use std::cmp::Ordering;
14use std::convert::Infallible;
15use std::fmt;
16use std::hash::{Hash, Hasher};
17use std::ops::Deref;
18
19use regex::{Error, RegexBuilder};
20use regex_syntax::ast::{self, Ast, ClassSetItem, Visitor};
21use serde::de::Error as DeError;
22use serde::ser::SerializeStruct;
23use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
24
25/// The maximum size of a regex after compilation.
26/// This is the same as the `Regex` crate's default at the time of writing.
27///
28/// Note: This number is mentioned in our user-facing docs at the "String operators" in the function
29/// reference.
30const MAX_REGEX_SIZE_AFTER_COMPILATION: usize = 10 * 1024 * 1024;
31
32/// We also need a separate limit for the size of regexes before compilation. Even though the
33/// `Regex` crate promises that using its `size_limit` option (which we set to the other limit,
34/// `MAX_REGEX_SIZE_AFTER_COMPILATION`) would prevent excessive resource usage, this doesn't seem to
35/// be the case. Since we compile regexes in envd, we need strict limits to prevent envd OOMs.
36/// See <https://github.com/MaterializeInc/database-issues/issues/9907> for an example.
37///
38/// This bounds the AST's node count, since every node needs at least one pattern byte, and with it
39/// every node kind whose cost is bounded. Character classes are not, hence
40/// `MAX_REGEX_CHARACTER_CLASSES`.
41///
42/// Note: This number is mentioned in our user-facing docs at the "String operators" in the function
43/// reference.
44const MAX_REGEX_SIZE_BEFORE_COMPILATION: usize = 1 * 1024 * 1024;
45
46/// The maximum number of character classes a pattern may contain.
47///
48/// Byte length cannot bound what a compile spends, and neither can `size_limit`, which covers only
49/// the compiled NFA. The memory goes to `regex-syntax` translating the AST into its HIR, where a
50/// character class expands to hundreds of Unicode ranges out of a handful of pattern bytes. `\p{L}`
51/// is five bytes, and under case folding `[a-\x{2FFF}]` is no cheaper, since the translator walks
52/// the range codepoint by codepoint keeping one range per fold mapping.
53/// See <https://github.com/MaterializeInc/database-issues/issues/9907>.
54///
55/// Counting rather than pricing is deliberate. Per-kind byte prices need calibrating against the
56/// pinned `regex-syntax` and fail silently when one is set too low, whereas a count only asks
57/// whether a kind can expand without bound, and over-counting merely costs a few legitimate
58/// patterns.
59///
60/// This and the byte limit are independent, and multiply out to a bound on one compile that
61/// `regex_two_limits_bound_what_a_compile_spends` holds against measurement.
62///
63/// Note: This number is mentioned in our user-facing docs at the "String operators" in the function
64/// reference.
65///
66/// NOTE: `\p{L}{200000}` stays one class in the AST. `size_limit` rejects its expansion later, in
67/// the NFA compiler.
68const MAX_REGEX_CHARACTER_CLASSES: usize = 2000;
69
70/// Counts the character classes in an AST, the node kinds whose translated size is not bounded by
71/// the pattern bytes that spell them.
72///
73/// Flags do not enter into it. Folding is the expensive direction for every kind, and `(?i)` turns
74/// it on from inside the pattern, out of reach of the flag [`Regex`] is built with, so a kind is
75/// judged by what it costs folded.
76///
77/// NOTE: the matches are exhaustive on purpose. A `regex-syntax` bump adding a node kind has to
78/// fail to compile here rather than default to "not a class", which no test could catch.
79struct CharacterClassCounter {
80    count: usize,
81}
82
83impl Visitor for CharacterClassCounter {
84    type Output = usize;
85    type Err = Infallible;
86
87    fn finish(self) -> Result<usize, Infallible> {
88        Ok(self.count)
89    }
90
91    fn visit_pre(&mut self, ast: &Ast) -> Result<(), Infallible> {
92        self.count += match ast {
93            Ast::ClassUnicode(_) | Ast::ClassPerl(_) => 1,
94            // A bracketed class carries no ranges itself. Its items do, and are counted below.
95            Ast::Empty(_)
96            | Ast::Flags(_)
97            | Ast::Literal(_)
98            | Ast::Dot(_)
99            | Ast::Assertion(_)
100            | Ast::ClassBracketed(_)
101            | Ast::Repetition(_)
102            | Ast::Group(_)
103            | Ast::Alternation(_)
104            | Ast::Concat(_) => 0,
105        };
106        Ok(())
107    }
108
109    fn visit_class_set_item_pre(&mut self, item: &ClassSetItem) -> Result<(), Infallible> {
110        // Items of a bracketed class, e.g. the `\p{L}` in `[a\p{L}]`. Each contributes its own
111        // ranges, and a union only merges ranges, so counting the parts bounds the whole.
112        self.count += match item {
113            ClassSetItem::Unicode(_)
114            | ClassSetItem::Perl(_)
115            | ClassSetItem::Ascii(_)
116            | ClassSetItem::Range(_) => 1,
117            ClassSetItem::Empty(_)
118            | ClassSetItem::Literal(_)
119            | ClassSetItem::Bracketed(_)
120            | ClassSetItem::Union(_) => 0,
121        };
122        Ok(())
123    }
124}
125
126/// Counts the character classes in `pattern`, or `None` if it does not parse.
127///
128/// Not an escape hatch: an unparseable pattern is left to [`RegexBuilder`], and both parse through
129/// the same `regex-syntax` with the same configuration (`nest_limit` 250, `octal` off), so a pattern
130/// that fails here fails there too.
131fn count_character_classes(pattern: &str) -> Option<usize> {
132    let ast = ast::parse::Parser::new().parse(pattern).ok()?;
133    match ast::visit(&ast, CharacterClassCounter { count: 0 }) {
134        Ok(count) => Some(count),
135        Err(infallible) => match infallible {},
136    }
137}
138
139/// A hashable, comparable, and serializable regular expression type.
140///
141/// The  [`regex::Regex`] type, the de facto standard regex type in Rust, does
142/// not implement [`PartialOrd`], [`Ord`] [`PartialEq`], [`Eq`], or [`Hash`].
143/// The omissions are reasonable. There is no natural definition of ordering for
144/// regexes. There *is* a natural definition of equality—whether two regexes
145/// describe the same regular language—but that is an expensive property to
146/// compute, and [`PartialEq`] is generally expected to be fast to compute.
147///
148/// This type wraps [`regex::Regex`] and imbues it with implementations of the
149/// above traits. Two regexes are considered equal iff their string
150/// representation is identical, plus flags, such as `case_insensitive`,
151/// are identical. The [`PartialOrd`], [`Ord`], and [`Hash`] implementations
152/// are similarly based upon the string representation plus flags. As
153/// mentioned above, this is not the natural equivalence relation for regexes: for
154/// example, the regexes `aa*` and `a+` define the same language, but would not
155/// compare as equal with this implementation of [`PartialEq`]. Still, it is
156/// often useful to have _some_ equivalence relation available (e.g., to store
157/// types containing regexes in a hashmap) even if the equivalence relation is
158/// imperfect.
159///
160/// [regex::Regex] is hard to serialize (because of the compiled code), so our approach is to
161/// instead serialize this wrapper struct, where we skip serializing the actual regex field, and
162/// we reconstruct the regex field from the other fields upon deserialization.
163/// (Earlier, serialization was buggy due to <https://github.com/tailhook/serde-regex/issues/14>,
164/// and also making the same mistake in our own protobuf serialization code.)
165#[derive(Debug, Clone)]
166pub struct Regex {
167    pub case_insensitive: bool,
168    pub dot_matches_new_line: bool,
169    pub regex: regex::Regex,
170}
171
172impl Regex {
173    /// A simple constructor for the default setting of `dot_matches_new_line: true`.
174    /// See <https://www.postgresql.org/docs/current/functions-matching.html#POSIX-MATCHING-RULES>
175    /// "newline-sensitive matching"
176    pub fn new(pattern: &str, case_insensitive: bool) -> Result<Regex, RegexCompilationError> {
177        Self::new_dot_matches_new_line(pattern, case_insensitive, true)
178    }
179
180    /// Allows explicitly setting `dot_matches_new_line`.
181    pub fn new_dot_matches_new_line(
182        pattern: &str,
183        case_insensitive: bool,
184        dot_matches_new_line: bool,
185    ) -> Result<Regex, RegexCompilationError> {
186        if pattern.len() > MAX_REGEX_SIZE_BEFORE_COMPILATION {
187            return Err(RegexCompilationError::PatternTooLarge {
188                pattern_size: pattern.len(),
189            });
190        }
191        if let Some(classes) = count_character_classes(pattern) {
192            if classes > MAX_REGEX_CHARACTER_CLASSES {
193                return Err(RegexCompilationError::TooManyCharacterClasses { classes });
194            }
195        }
196        let mut regex_builder = RegexBuilder::new(pattern);
197        regex_builder.case_insensitive(case_insensitive);
198        regex_builder.dot_matches_new_line(dot_matches_new_line);
199        regex_builder.size_limit(MAX_REGEX_SIZE_AFTER_COMPILATION);
200        Ok(Regex {
201            case_insensitive,
202            dot_matches_new_line,
203            regex: regex_builder.build()?,
204        })
205    }
206
207    /// Returns the pattern string of the regex.
208    pub fn pattern(&self) -> &str {
209        // `as_str` returns the raw pattern as provided during construction,
210        // and doesn't include any of the flags.
211        self.regex.as_str()
212    }
213}
214
215/// Error type for regex compilation failures.
216#[derive(Debug, Clone)]
217pub enum RegexCompilationError {
218    /// Wrapper for regex crate's Error type.
219    RegexError(Error),
220    /// Regex pattern size exceeds MAX_REGEX_SIZE_BEFORE_COMPILATION.
221    PatternTooLarge { pattern_size: usize },
222    /// Regex pattern contains more than MAX_REGEX_CHARACTER_CLASSES character classes.
223    TooManyCharacterClasses { classes: usize },
224}
225
226impl fmt::Display for RegexCompilationError {
227    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
228        match self {
229            RegexCompilationError::RegexError(e) => write!(f, "{}", e),
230            RegexCompilationError::PatternTooLarge {
231                pattern_size: patter_size,
232            } => write!(
233                f,
234                "regex pattern too large ({} bytes, max {} bytes)",
235                patter_size, MAX_REGEX_SIZE_BEFORE_COMPILATION
236            ),
237            RegexCompilationError::TooManyCharacterClasses { classes } => write!(
238                f,
239                "regex pattern has too many character classes ({}, max {}). \
240                 A character class is a Unicode, Perl or POSIX class such as `\\p{{L}}`, `\\d` \
241                 or `[[:alpha:]]`, or a range such as `a-z`",
242                classes, MAX_REGEX_CHARACTER_CLASSES
243            ),
244        }
245    }
246}
247
248impl From<Error> for RegexCompilationError {
249    fn from(e: Error) -> Self {
250        RegexCompilationError::RegexError(e)
251    }
252}
253
254impl PartialEq<Regex> for Regex {
255    fn eq(&self, other: &Regex) -> bool {
256        self.pattern() == other.pattern()
257            && self.case_insensitive == other.case_insensitive
258            && self.dot_matches_new_line == other.dot_matches_new_line
259    }
260}
261
262impl Eq for Regex {}
263
264impl PartialOrd for Regex {
265    fn partial_cmp(&self, other: &Regex) -> Option<Ordering> {
266        Some(self.cmp(other))
267    }
268}
269
270impl Ord for Regex {
271    fn cmp(&self, other: &Regex) -> Ordering {
272        (
273            self.pattern(),
274            self.case_insensitive,
275            self.dot_matches_new_line,
276        )
277            .cmp(&(
278                other.pattern(),
279                other.case_insensitive,
280                other.dot_matches_new_line,
281            ))
282    }
283}
284
285impl Hash for Regex {
286    fn hash<H: Hasher>(&self, hasher: &mut H) {
287        self.pattern().hash(hasher);
288        self.case_insensitive.hash(hasher);
289        self.dot_matches_new_line.hash(hasher);
290    }
291}
292
293impl Deref for Regex {
294    type Target = regex::Regex;
295
296    fn deref(&self) -> &regex::Regex {
297        &self.regex
298    }
299}
300
301impl Serialize for Regex {
302    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
303    where
304        S: Serializer,
305    {
306        let mut state = serializer.serialize_struct("Regex", 3)?;
307        state.serialize_field("pattern", &self.pattern())?;
308        state.serialize_field("case_insensitive", &self.case_insensitive)?;
309        state.serialize_field("dot_matches_new_line", &self.dot_matches_new_line)?;
310        state.end()
311    }
312}
313
314impl<'de> Deserialize<'de> for Regex {
315    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
316    where
317        D: Deserializer<'de>,
318    {
319        enum Field {
320            Pattern,
321            CaseInsensitive,
322            DotMatchesNewLine,
323        }
324
325        impl<'de> Deserialize<'de> for Field {
326            fn deserialize<D>(deserializer: D) -> Result<Field, D::Error>
327            where
328                D: Deserializer<'de>,
329            {
330                struct FieldVisitor;
331
332                impl<'de> de::Visitor<'de> for FieldVisitor {
333                    type Value = Field;
334
335                    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
336                        formatter.write_str(
337                            "pattern string or case_insensitive bool or dot_matches_new_line bool",
338                        )
339                    }
340
341                    fn visit_str<E>(self, value: &str) -> Result<Field, E>
342                    where
343                        E: de::Error,
344                    {
345                        match value {
346                            "pattern" => Ok(Field::Pattern),
347                            "case_insensitive" => Ok(Field::CaseInsensitive),
348                            "dot_matches_new_line" => Ok(Field::DotMatchesNewLine),
349                            _ => Err(de::Error::unknown_field(value, FIELDS)),
350                        }
351                    }
352                }
353
354                deserializer.deserialize_identifier(FieldVisitor)
355            }
356        }
357
358        struct RegexVisitor;
359
360        impl<'de> de::Visitor<'de> for RegexVisitor {
361            type Value = Regex;
362
363            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
364                formatter.write_str("Regex serialized by the manual Serialize impl from above")
365            }
366
367            fn visit_seq<V>(self, mut seq: V) -> Result<Regex, V::Error>
368            where
369                V: de::SeqAccess<'de>,
370            {
371                let pattern = seq
372                    .next_element::<Cow<str>>()?
373                    .ok_or_else(|| de::Error::invalid_length(0, &self))?;
374                let case_insensitive = seq
375                    .next_element()?
376                    .ok_or_else(|| de::Error::invalid_length(1, &self))?;
377                let dot_matches_new_line = seq
378                    .next_element()?
379                    .ok_or_else(|| de::Error::invalid_length(2, &self))?;
380                Regex::new_dot_matches_new_line(&pattern, case_insensitive, dot_matches_new_line)
381                    .map_err(|err| {
382                        V::Error::custom(format!(
383                            "Unable to recreate regex during deserialization: {}",
384                            err
385                        ))
386                    })
387            }
388
389            fn visit_map<V>(self, mut map: V) -> Result<Regex, V::Error>
390            where
391                V: de::MapAccess<'de>,
392            {
393                let mut pattern: Option<Cow<str>> = None;
394                let mut case_insensitive: Option<bool> = None;
395                let mut dot_matches_new_line: Option<bool> = None;
396                while let Some(key) = map.next_key()? {
397                    match key {
398                        Field::Pattern => {
399                            if pattern.is_some() {
400                                return Err(de::Error::duplicate_field("pattern"));
401                            }
402                            pattern = Some(map.next_value()?);
403                        }
404                        Field::CaseInsensitive => {
405                            if case_insensitive.is_some() {
406                                return Err(de::Error::duplicate_field("case_insensitive"));
407                            }
408                            case_insensitive = Some(map.next_value()?);
409                        }
410                        Field::DotMatchesNewLine => {
411                            if dot_matches_new_line.is_some() {
412                                return Err(de::Error::duplicate_field("dot_matches_new_line"));
413                            }
414                            dot_matches_new_line = Some(map.next_value()?);
415                        }
416                    }
417                }
418                let pattern = pattern.ok_or_else(|| de::Error::missing_field("pattern"))?;
419                let case_insensitive =
420                    case_insensitive.ok_or_else(|| de::Error::missing_field("case_insensitive"))?;
421                let dot_matches_new_line = dot_matches_new_line
422                    .ok_or_else(|| de::Error::missing_field("dot_matches_new_line"))?;
423                Regex::new_dot_matches_new_line(&pattern, case_insensitive, dot_matches_new_line)
424                    .map_err(|err| {
425                        V::Error::custom(format!(
426                            "Unable to recreate regex during deserialization: {}",
427                            err
428                        ))
429                    })
430            }
431        }
432
433        const FIELDS: &[&str] = &["pattern", "case_insensitive", "dot_matches_new_line"];
434        deserializer.deserialize_struct("Regex", FIELDS, RegexVisitor)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use std::alloc::{GlobalAlloc, Layout, System};
441    use std::cell::Cell;
442
443    use regex_syntax::hir::translate::TranslatorBuilder;
444
445    use super::*;
446
447    /// Wraps the system allocator to record the peak heap the calling thread has asked for.
448    ///
449    /// Counters are per thread, so tests running in parallel do not perturb each other. Bytes
450    /// requested are counted, not resident bytes, which keeps a measurement reproducible across
451    /// allocators at the cost of running a little above true RSS.
452    struct TrackingAllocator;
453
454    thread_local! {
455        /// Bytes this thread has requested and not yet freed.
456        static LIVE_BYTES: Cell<usize> = const { Cell::new(0) };
457        /// High-water mark of `LIVE_BYTES` since the last [`peak_translate_bytes`] reset.
458        static PEAK_BYTES: Cell<usize> = const { Cell::new(0) };
459    }
460
461    /// NOTE: keep the bookkeeping allocation-free. A `Cell<usize>` behind a `const`-initialized
462    /// `thread_local!` neither allocates on first access nor registers a destructor, so it cannot
463    /// re-enter the allocator. `try_with` covers access during thread teardown.
464    fn record_alloc(size: usize) {
465        let _ = LIVE_BYTES.try_with(|live| {
466            let now = live.get().saturating_add(size);
467            live.set(now);
468            let _ = PEAK_BYTES.try_with(|peak| {
469                if now > peak.get() {
470                    peak.set(now);
471                }
472            });
473        });
474    }
475
476    fn record_dealloc(size: usize) {
477        let _ = LIVE_BYTES.try_with(|live| live.set(live.get().saturating_sub(size)));
478    }
479
480    // SAFETY: every method forwards to `System` unchanged, so the allocator contract is whatever
481    // `System` guarantees. The counters are observation only and never influence a returned
482    // pointer.
483    unsafe impl GlobalAlloc for TrackingAllocator {
484        unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
485            let ptr = unsafe { System.alloc(layout) };
486            if !ptr.is_null() {
487                record_alloc(layout.size());
488            }
489            ptr
490        }
491
492        unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
493            let ptr = unsafe { System.alloc_zeroed(layout) };
494            if !ptr.is_null() {
495                record_alloc(layout.size());
496            }
497            ptr
498        }
499
500        unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
501            record_dealloc(layout.size());
502            unsafe { System.dealloc(ptr, layout) }
503        }
504
505        unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
506            // Charged before the call and released after, so a growing `Vec` counts both blocks
507            // at once. An out-of-place realloc does hold both, and the peak has to reflect that.
508            record_alloc(new_size);
509            let new_ptr = unsafe { System.realloc(ptr, layout, new_size) };
510            record_dealloc(if new_ptr.is_null() {
511                new_size
512            } else {
513                layout.size()
514            });
515            new_ptr
516        }
517    }
518
519    #[global_allocator]
520    static ALLOC: TrackingAllocator = TrackingAllocator;
521
522    /// Peak heap the calling thread requests while parsing `pattern` and translating it to HIR,
523    /// the stage the two limits bound.
524    ///
525    /// Compiling the NFA afterwards is left out: `size_limit` bounds it, and its near-constant cost
526    /// would swamp the per-node signal at the pattern sizes a test can afford. The translator flags
527    /// mirror [`Regex::new_dot_matches_new_line`], since `case_insensitive` alone moves a class's
528    /// cost by an order of magnitude.
529    fn peak_translate_bytes(pattern: &str, case_insensitive: bool) -> usize {
530        let base = LIVE_BYTES.with(|live| live.get());
531        PEAK_BYTES.with(|peak| peak.set(base));
532        {
533            let ast = ast::parse::Parser::new()
534                .parse(pattern)
535                .expect("pattern parses");
536            let mut translator = TranslatorBuilder::new();
537            translator.case_insensitive(case_insensitive);
538            translator.dot_matches_new_line(true);
539            let hir = translator
540                .build()
541                .translate(pattern, &ast)
542                .expect("pattern translates");
543            // The HIR and the AST are both live at the peak of a real compile, so hold them here.
544            std::hint::black_box((&ast, &hir));
545        }
546        PEAK_BYTES.with(|peak| peak.get()).saturating_sub(base)
547    }
548
549    /// A class-heavy pattern one byte under `MAX_REGEX_SIZE_BEFORE_COMPILATION` costs gigabytes to
550    /// translate, and is reachable from an unprivileged `SELECT 'x' ~* <pattern>`, so it has to be
551    /// rejected without compiling.
552    #[mz_ore::test]
553    #[cfg_attr(miri, ignore)] // too slow
554    fn regex_class_heavy_pattern_rejected_before_compiling() {
555        let pattern = r"\p{L}".repeat(MAX_REGEX_SIZE_BEFORE_COMPILATION / r"\p{L}".len());
556        assert!(pattern.len() <= MAX_REGEX_SIZE_BEFORE_COMPILATION);
557        // The count does not depend on the flag, so both directions must be rejected.
558        for case_insensitive in [true, false] {
559            let err = Regex::new(&pattern, case_insensitive).expect_err("must be rejected");
560            assert!(
561                matches!(err, RegexCompilationError::TooManyCharacterClasses { .. }),
562                "expected TooManyCharacterClasses, got {err:?}"
563            );
564        }
565    }
566
567    /// Case folding walks a range's whole span, so a wide one buys as many Unicode ranges per
568    /// pattern byte as `\p{...}` does. It has to count as a class, not as the two codepoints it
569    /// spells.
570    #[mz_ore::test]
571    #[cfg_attr(miri, ignore)] // too slow
572    fn regex_wide_bracketed_range_counts_as_a_class() {
573        let unit = r"[a-\x{2FFF}]";
574        let pattern = unit.repeat(MAX_REGEX_SIZE_BEFORE_COMPILATION / unit.len());
575        // `(?i)` reaches the same folding path from inside the pattern, so the flag we build with
576        // must make no difference.
577        for case_insensitive in [true, false] {
578            let err = Regex::new(&pattern, case_insensitive).expect_err("must be rejected");
579            assert!(
580                matches!(err, RegexCompilationError::TooManyCharacterClasses { .. }),
581                "expected TooManyCharacterClasses, got {err:?}"
582            );
583        }
584    }
585
586    /// Nested classes have to count too, else `[\p{L}]` evades the limit while costing what
587    /// `\p{L}` costs.
588    #[mz_ore::test]
589    #[cfg_attr(miri, ignore)] // too slow
590    fn regex_bracketed_class_is_counted() {
591        let unit = r"[\p{L}]";
592        let pattern = unit.repeat(MAX_REGEX_SIZE_BEFORE_COMPILATION / unit.len());
593        let err = Regex::new(&pattern, true).expect_err("must be rejected");
594        assert!(
595            matches!(err, RegexCompilationError::TooManyCharacterClasses { .. }),
596            "expected TooManyCharacterClasses, got {err:?}"
597        );
598    }
599
600    /// The limit has to admit as well as reject: a pattern at the limit reaches the compiler, one
601    /// class past it does not.
602    #[mz_ore::test]
603    #[cfg_attr(miri, ignore)] // Compiling thousands of classes is far too slow under miri.
604    fn regex_class_limit_boundary_is_where_the_constant_puts_it() {
605        let unit = r"\p{L}";
606        assert!(
607            unit.len() * (MAX_REGEX_CHARACTER_CLASSES + 1) <= MAX_REGEX_SIZE_BEFORE_COMPILATION
608        );
609
610        // Case-sensitive on purpose: this drives a real compile, and folding costs many times the
611        // memory for no extra coverage. Whether that compile succeeds or hits `size_limit` is not
612        // this test's business, only that our own limit lets it through.
613        let at_limit = Regex::new(&unit.repeat(MAX_REGEX_CHARACTER_CLASSES), false);
614        assert!(
615            !matches!(
616                at_limit,
617                Err(RegexCompilationError::TooManyCharacterClasses { .. })
618            ),
619            "a pattern at the limit must reach the compiler, got {at_limit:?}"
620        );
621
622        let err = Regex::new(&unit.repeat(MAX_REGEX_CHARACTER_CLASSES + 1), false)
623            .expect_err("must be rejected");
624        assert!(
625            matches!(err, RegexCompilationError::TooManyCharacterClasses { .. }),
626            "one class over the limit must be turned away, got {err:?}"
627        );
628    }
629
630    /// Literals carry no character classes, so a large pattern of them is bounded by its bytes
631    /// alone and still has to compile.
632    #[mz_ore::test]
633    #[cfg_attr(miri, ignore)] // too slow
634    fn regex_long_literal_pattern_still_compiles() {
635        // A long alternation of literals, the shape a generated pattern takes. Kept at 12000
636        // branches, past which the NFA runs into MAX_REGEX_SIZE_AFTER_COMPILATION instead.
637        let pattern = vec!["abcdefgh"; 12_000].join("|");
638        assert!(pattern.len() > 100 * 1024);
639        assert_eq!(count_character_classes(&pattern), Some(0));
640        assert!(Regex::new(&pattern, true).is_ok());
641    }
642
643    /// The limits bound a compile only if the per-item ceilings below hold, and those are facts
644    /// about the pinned `regex-syntax`, so measure them. A bump that makes a node kind pricier
645    /// fails here.
646    ///
647    /// Each case also pins how [`count_character_classes`] classifies its kind, the one judgement
648    /// the limits rest on. A kind left uncounted while it can expand without bound shows up as the
649    /// measured cost outgrowing the bytes that bought it.
650    #[mz_ore::test]
651    #[cfg_attr(miri, ignore)] // Unicode class translation is far too slow under miri.
652    fn regex_two_limits_bound_what_a_compile_spends() {
653        /// Ceiling on the heap spent translating one character class. However wide a range is, it
654        /// keeps at most one range per entry of the simple case-fold table, so it stays under this.
655        const MAX_MEMORY_PER_CHARACTER_CLASS: usize = 96 * 1024;
656        /// Ceiling on the heap spent translating one node that is not a character class. A literal
657        /// under `i` is the costliest, since it folds to a small class rather than staying a byte.
658        const MAX_MEMORY_PER_AST_NODE: usize = 768;
659
660        /// Enough repetitions for a class's own cost to dominate a compile's fixed cost, few
661        /// enough that a case stays within a few tens of megabytes.
662        const CLASS_UNITS: usize = 150;
663        /// A non-class node costs orders of magnitude less, so it needs proportionally more
664        /// repetitions to clear that same bar.
665        const CHEAP_UNITS: usize = 10_000;
666
667        // (unit, case_insensitive, repetitions, character classes per repetition)
668        let cases: &[(&str, bool, usize, usize)] = &[
669            // Unicode-property and Perl classes. `\p{Grapheme_Base}` under `i` is the costliest
670            // single class found, so it is what sizes `MAX_MEMORY_PER_CHARACTER_CLASS`.
671            (r"\p{Grapheme_Base}", true, CLASS_UNITS, 1),
672            (r"\p{XID_Continue}", true, CLASS_UNITS, 1),
673            (r"\p{Alphabetic}", true, CLASS_UNITS, 1),
674            (r"\p{L}", true, CLASS_UNITS, 1),
675            (r"\p{L}", false, CLASS_UNITS, 1),
676            (r"\w", true, CLASS_UNITS, 1),
677            (r"\W", true, CLASS_UNITS, 1),
678            (r"\d", true, CLASS_UNITS, 1),
679            (r"\s", true, CLASS_UNITS, 1),
680            // The same nested in a bracketed class, where every item counts on its own.
681            (r"[\p{L}]", true, CLASS_UNITS, 1),
682            (r"[a\p{L}\d]", true, CLASS_UNITS, 2),
683            // Ranges, from one too narrow to reach the case-fold table up to one covering all of
684            // it, including the stretch of Latin where it is densest. Span alone does not predict
685            // the cost: `[\x{100}-\x{17F}]` costs 2.5x what the equally wide `[\x00-\x7F]` does,
686            // which is why ranges are counted rather than priced.
687            (r"[a-\x{10FFFF}]", true, CLASS_UNITS, 1),
688            (r"[^a-\x{10FFFF}]", true, CLASS_UNITS, 1),
689            (r"[a-\x{2FFF}]", true, CLASS_UNITS, 1),
690            (r"[\x{100}-\x{250}]", true, CLASS_UNITS, 1),
691            (r"[\x{100}-\x{17F}]", true, CLASS_UNITS, 1),
692            (r"[a-\x{FF}]", true, CLASS_UNITS, 1),
693            (r"[a-z]", true, CLASS_UNITS, 1),
694            (r"[[:alpha:]]", true, CLASS_UNITS, 1),
695            // Nodes carrying no class, bounded by the byte limit alone. `[abc]` is here on
696            // purpose: a bracketed class of literals is not counted, so its cost has to stay
697            // within what its bytes buy.
698            ("a", true, CHEAP_UNITS, 0),
699            ("a", false, CHEAP_UNITS, 0),
700            (".", true, CHEAP_UNITS, 0),
701            ("[abc]", true, CHEAP_UNITS, 0),
702            ("(a)", false, CHEAP_UNITS, 0),
703            ("a|", false, CHEAP_UNITS, 0),
704            ("a*", false, CHEAP_UNITS, 0),
705            ("a{2}", false, CHEAP_UNITS, 0),
706            ("^", false, CHEAP_UNITS, 0),
707        ];
708
709        for (unit, case_insensitive, repeats, classes_per_unit) in cases {
710            let pattern = unit.repeat(*repeats);
711            let expected_classes = repeats * classes_per_unit;
712            let classes = count_character_classes(&pattern).expect("pattern parses");
713            assert_eq!(
714                classes, expected_classes,
715                "`{unit}` x{repeats} counts as {classes} character classes, expected \
716                 {expected_classes}"
717            );
718
719            // The same product the two limits bound, evaluated for this pattern. Pattern bytes
720            // stand in for the node count, which they bound.
721            let bound =
722                classes * MAX_MEMORY_PER_CHARACTER_CLASS + pattern.len() * MAX_MEMORY_PER_AST_NODE;
723            let measured = peak_translate_bytes(&pattern, *case_insensitive);
724            assert!(
725                measured <= bound,
726                "`{unit}` x{repeats} (case_insensitive: {case_insensitive}) allocated {measured} \
727                 bytes, above the {bound} bytes the limits allow it, so they no longer bound what \
728                 a compile spends"
729            );
730        }
731    }
732
733    /// A counted repetition stays one class in the AST, and the NFA compiler's incremental
734    /// `size_limit` check rejects it. Pin that, since the count does not multiply by repetition
735    /// bounds.
736    #[mz_ore::test]
737    #[cfg_attr(miri, ignore)] // too slow
738    fn regex_counted_repetition_rejected_by_size_limit() {
739        let err = Regex::new(r"\p{L}{200000}", true).expect_err("must be rejected");
740        assert!(
741            matches!(err, RegexCompilationError::RegexError(_)),
742            "expected the regex crate's own error, got {err:?}"
743        );
744    }
745
746    /// Short patterns, the overwhelmingly common case, must be unaffected.
747    #[mz_ore::test]
748    #[cfg_attr(miri, ignore)] // too slow
749    fn regex_ordinary_patterns_unaffected() {
750        for pattern in [
751            r"a+b",
752            r"^\d{3}-\d{4}$",
753            r"\p{L}+",
754            r"[a-zA-Z0-9_]*",
755            r"(?i)foo|bar",
756        ] {
757            assert!(
758                Regex::new(pattern, false).is_ok(),
759                "{pattern} should compile"
760            );
761        }
762    }
763
764    /// A pattern we cannot parse must fall through to `RegexBuilder`, so users keep getting the
765    /// regex crate's error message rather than one about the budget.
766    #[mz_ore::test]
767    #[cfg_attr(miri, ignore)] // too slow
768    fn regex_unparseable_pattern_reports_regex_error() {
769        let err = Regex::new("(", false).expect_err("must be rejected");
770        assert!(
771            matches!(err, RegexCompilationError::RegexError(_)),
772            "expected the regex crate's own error, got {err:?}"
773        );
774    }
775
776    /// This was failing before due to the derived serde serialization being incorrect, because of
777    /// <https://github.com/tailhook/serde-regex/issues/14>.
778    /// Nowadays, we use our own handwritten Serialize/Deserialize impls for our Regex wrapper struct.
779    #[mz_ore::test]
780    fn regex_serde_case_insensitive() {
781        let pattern = "AAA";
782        let orig_regex = Regex::new(pattern, true).unwrap();
783        let serialized: String = serde_json::to_string(&orig_regex).unwrap();
784        let roundtrip_result: Regex = serde_json::from_str(&serialized).unwrap();
785        // Equality test between orig and roundtrip_result wouldn't work, because Eq doesn't test
786        // the actual regex object. So test the actual regex functionality (concentrating on case
787        // sensitivity).
788        assert_eq!(orig_regex.regex.is_match("aaa"), true);
789        assert_eq!(roundtrip_result.regex.is_match("aaa"), true);
790        assert_eq!(pattern, roundtrip_result.pattern());
791    }
792
793    /// Test the roundtripping of `dot_matches_new_line`.
794    /// (Similar to the above `regex_serde_case_insensitive`.)
795    #[mz_ore::test]
796    fn regex_serde_dot_matches_new_line() {
797        {
798            // dot_matches_new_line: true
799            let pattern = "A.*B";
800            let orig_regex = Regex::new_dot_matches_new_line(pattern, true, true).unwrap();
801            let serialized: String = serde_json::to_string(&orig_regex).unwrap();
802            let roundtrip_result: Regex = serde_json::from_str(&serialized).unwrap();
803            assert_eq!(orig_regex.regex.is_match("axxx\nxxxb"), true);
804            assert_eq!(roundtrip_result.regex.is_match("axxx\nxxxb"), true);
805            assert_eq!(pattern, roundtrip_result.pattern());
806        }
807        {
808            // dot_matches_new_line: false
809            let pattern = "A.*B";
810            let orig_regex = Regex::new_dot_matches_new_line(pattern, true, false).unwrap();
811            let serialized: String = serde_json::to_string(&orig_regex).unwrap();
812            let roundtrip_result: Regex = serde_json::from_str(&serialized).unwrap();
813            assert_eq!(orig_regex.regex.is_match("axxx\nxxxb"), false);
814            assert_eq!(roundtrip_result.regex.is_match("axxx\nxxxb"), false);
815            assert_eq!(pattern, roundtrip_result.pattern());
816        }
817        {
818            // dot_matches_new_line: default
819            let pattern = "A.*B";
820            let orig_regex = Regex::new(pattern, true).unwrap();
821            let serialized: String = serde_json::to_string(&orig_regex).unwrap();
822            let roundtrip_result: Regex = serde_json::from_str(&serialized).unwrap();
823            assert_eq!(orig_regex.regex.is_match("axxx\nxxxb"), true);
824            assert_eq!(roundtrip_result.regex.is_match("axxx\nxxxb"), true);
825            assert_eq!(pattern, roundtrip_result.pattern());
826        }
827    }
828
829    #[mz_ore::test]
830    fn regex_serde_from_reader() {
831        let pattern = "A.*B";
832        let orig_regex = Regex::new_dot_matches_new_line(pattern, true, true).unwrap();
833
834        let serialized: String = serde_json::to_string(&orig_regex).unwrap();
835        let roundtrip_result: Regex = serde_json::from_reader(serialized.as_bytes()).unwrap();
836
837        assert_eq!(orig_regex.regex.is_match("axxx\nxxxb"), true);
838        assert_eq!(roundtrip_result.regex.is_match("axxx\nxxxb"), true);
839        assert_eq!(pattern, roundtrip_result.pattern());
840
841        let serialized = bincode::serialize(&orig_regex).unwrap();
842        let roundtrip_result: Regex = bincode::deserialize_from(&*serialized).unwrap();
843
844        assert_eq!(orig_regex.regex.is_match("axxx\nxxxb"), true);
845        assert_eq!(roundtrip_result.regex.is_match("axxx\nxxxb"), true);
846        assert_eq!(pattern, roundtrip_result.pattern());
847    }
848}