Skip to main content

eggress_pproxy_compat/
regex_compat.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4/// Maximum pattern length for compiled regexes (compile-time guard).
5const MAX_PATTERN_LEN: usize = 4096;
6
7/// Maximum number of rule entries per file.
8const MAX_RULE_ENTRIES: usize = 10_000;
9
10/// Explicit fancy_regex backtracking limit applied to the compatibility backend.
11/// Matches the fancy_regex 0.14 default (1,000,000 steps) and makes the bound
12/// stable and testable rather than relying on the opaque dependency default.
13const FANCY_REGEX_BACKTRACK_LIMIT: usize = 1_000_000;
14
15/// Regex backend used for pattern compilation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum RegexBackend {
18    /// Native Rust `regex` crate (fast, no look-around/backreferences).
19    Fast,
20    /// `fancy_regex` crate (Perl/Python-like features: look-around, backtracking).
21    Fancy,
22}
23
24impl fmt::Display for RegexBackend {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        match self {
27            Self::Fast => f.write_str("fast"),
28            Self::Fancy => f.write_str("fancy"),
29        }
30    }
31}
32
33/// A compiled regex that uses either the fast `regex` backend or the
34/// `fancy_regex` backend for pproxy compatibility mode.
35///
36/// The `fancy_regex` backend supports Perl/Python-like constructs such as
37/// look-around and backreferences, which are common in pproxy rule files.
38/// The fast backend is used when fancy features are not needed.
39#[derive(Clone)]
40pub enum CompatRegex {
41    Fast(regex::Regex),
42    Fancy(fancy_regex::Regex),
43}
44
45impl fmt::Debug for CompatRegex {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Self::Fast(r) => write!(f, "CompatRegex::Fast({})", r.as_str()),
49            Self::Fancy(r) => write!(f, "CompatRegex::Fancy({})", r.as_str()),
50        }
51    }
52}
53
54impl CompatRegex {
55    /// Try to compile a pattern using the fast `regex` backend first.
56    /// Falls back to `fancy_regex` if the pattern contains unsupported
57    /// constructs (look-around, backreferences, etc.).
58    pub fn compile(pattern: &str) -> Result<Self, RegexCompileError> {
59        if pattern.len() > MAX_PATTERN_LEN {
60            return Err(RegexCompileError::PatternTooLong {
61                len: pattern.len(),
62                max: MAX_PATTERN_LEN,
63            });
64        }
65
66        // Try fast regex first
67        match regex::Regex::new(pattern) {
68            Ok(r) => Ok(Self::Fast(r)),
69            Err(_) => {
70                // Fall back to fancy_regex for Perl/Python-like constructs
71                match fancy_regex::RegexBuilder::new(pattern)
72                    .backtrack_limit(FANCY_REGEX_BACKTRACK_LIMIT)
73                    .build()
74                {
75                    Ok(r) => Ok(Self::Fancy(r)),
76                    Err(e) => Err(RegexCompileError::CompileError {
77                        pattern: pattern.to_string(),
78                        message: e.to_string(),
79                    }),
80                }
81            }
82        }
83    }
84
85    /// Compile using only the fancy_regex backend (force compatibility mode).
86    pub fn compile_fancy(pattern: &str) -> Result<Self, RegexCompileError> {
87        if pattern.len() > MAX_PATTERN_LEN {
88            return Err(RegexCompileError::PatternTooLong {
89                len: pattern.len(),
90                max: MAX_PATTERN_LEN,
91            });
92        }
93
94        match fancy_regex::RegexBuilder::new(pattern)
95            .backtrack_limit(FANCY_REGEX_BACKTRACK_LIMIT)
96            .build()
97        {
98            Ok(r) => Ok(Self::Fancy(r)),
99            Err(e) => Err(RegexCompileError::CompileError {
100                pattern: pattern.to_string(),
101                message: e.to_string(),
102            }),
103        }
104    }
105
106    /// Returns true if the given text matches this regex.
107    pub fn is_match(&self, text: &str) -> Result<bool, RegexMatchError> {
108        match self {
109            Self::Fast(r) => Ok(r.is_match(text)),
110            Self::Fancy(r) => r.is_match(text).map_err(|e| RegexMatchError {
111                pattern: r.as_str().to_string(),
112                message: e.to_string(),
113            }),
114        }
115    }
116
117    /// Returns the backend used for this compiled regex.
118    pub fn backend(&self) -> RegexBackend {
119        match self {
120            Self::Fast(_) => RegexBackend::Fast,
121            Self::Fancy(_) => RegexBackend::Fancy,
122        }
123    }
124
125    /// Returns the original pattern string.
126    pub fn as_str(&self) -> &str {
127        match self {
128            Self::Fast(r) => r.as_str(),
129            Self::Fancy(r) => r.as_str(),
130        }
131    }
132
133    /// Returns true if this regex was compiled with the fancy backend.
134    pub fn is_fancy(&self) -> bool {
135        matches!(self, Self::Fancy(_))
136    }
137}
138
139/// Error during regex compilation.
140#[derive(Debug, Clone)]
141pub enum RegexCompileError {
142    /// Pattern exceeds the maximum allowed length.
143    PatternTooLong { len: usize, max: usize },
144    /// The pattern could not be compiled by either backend.
145    CompileError { pattern: String, message: String },
146}
147
148impl fmt::Display for RegexCompileError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Self::PatternTooLong { len, max } => {
152                write!(f, "pattern too long: {} bytes (max {})", len, max)
153            }
154            Self::CompileError { pattern, message } => {
155                write!(f, "failed to compile regex '{}': {}", pattern, message)
156            }
157        }
158    }
159}
160
161impl std::error::Error for RegexCompileError {}
162
163/// Error during regex matching.
164#[derive(Debug, Clone)]
165pub struct RegexMatchError {
166    pub pattern: String,
167    pub message: String,
168}
169
170impl fmt::Display for RegexMatchError {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(
173            f,
174            "regex match failed for '{}': {}",
175            self.pattern, self.message
176        )
177    }
178}
179
180impl std::error::Error for RegexMatchError {}
181
182/// A diagnostic produced during rulefile loading or regex compilation.
183#[derive(Debug, Clone)]
184pub struct RuleDiagnostic {
185    /// Line number in the rule file (1-indexed).
186    pub line_number: Option<usize>,
187    /// Severity level.
188    pub severity: RuleSeverity,
189    /// Human-readable message.
190    pub message: String,
191}
192
193/// Severity of a rule diagnostic.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
195pub enum RuleSeverity {
196    /// Informational note (e.g., fancy_regex backend used).
197    Info,
198    /// Warning about partial compatibility or degraded behavior.
199    Warning,
200    /// Error that prevented a rule from being loaded.
201    Error,
202}
203
204impl fmt::Display for RuleSeverity {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            Self::Info => f.write_str("info"),
208            Self::Warning => f.write_str("warning"),
209            Self::Error => f.write_str("error"),
210        }
211    }
212}
213
214/// A single parsed entry from a pproxy rule file.
215#[derive(Debug, Clone)]
216pub struct PproxyRuleEntry {
217    /// Line number in the file (1-indexed).
218    pub line_number: usize,
219    /// Raw pattern string from the file.
220    pub raw: String,
221    /// Compiled regex (fast or fancy depending on pattern).
222    pub regex: CompatRegex,
223    /// Whether this rule uses the fancy_regex backend.
224    pub uses_fancy: bool,
225}
226
227/// A loaded pproxy rule file with parsed entries and diagnostics.
228#[derive(Debug)]
229pub struct PproxyRuleFile {
230    /// Path to the rule file.
231    pub path: PathBuf,
232    /// Parsed and compiled rule entries.
233    pub entries: Vec<PproxyRuleEntry>,
234    /// Diagnostics produced during loading.
235    pub diagnostics: Vec<RuleDiagnostic>,
236}
237
238impl PproxyRuleFile {
239    /// Load and parse a pproxy-style rule file.
240    ///
241    /// Rule file format:
242    /// - Lines starting with `#` are comments (ignored).
243    /// - Empty lines are ignored.
244    /// - Every other line is a regular-expression alternative.
245    ///
246    /// This matches pproxy 2.7.9's `compile_rule`: it joins non-comment,
247    /// non-empty lines into one expression. The `pattern -> action` syntax is
248    /// accepted as an extension for older Eggress files, but is not a pproxy
249    /// rule-file format and therefore remains a diagnostic rather than a
250    /// routing action.
251    pub fn load(path: &Path) -> Result<Self, RegexCompileError> {
252        let content =
253            std::fs::read_to_string(path).map_err(|e| RegexCompileError::CompileError {
254                pattern: String::new(),
255                message: format!("failed to read '{}': {}", path.display(), e),
256            })?;
257
258        let mut entries = Vec::new();
259        let mut diagnostics = Vec::new();
260
261        for (line_num, line) in content.lines().enumerate() {
262            let line = line.trim();
263            let line_number = line_num + 1;
264
265            // Skip comments and blank lines
266            if line.is_empty() || line.starts_with('#') {
267                continue;
268            }
269
270            if entries.len() >= MAX_RULE_ENTRIES {
271                diagnostics.push(RuleDiagnostic {
272                    line_number: Some(line_number),
273                    severity: RuleSeverity::Error,
274                    message: format!(
275                        "rule file exceeds maximum of {} entries; remaining lines ignored",
276                        MAX_RULE_ENTRIES
277                    ),
278                });
279                break;
280            }
281
282            let pattern = if let Some((pattern, action)) = line.split_once("->") {
283                diagnostics.push(RuleDiagnostic {
284                    line_number: Some(line_number),
285                    severity: RuleSeverity::Warning,
286                    message: format!(
287                        "line {}: action suffix '{}' is not part of pproxy's regex-line format; using pattern only",
288                        line_number,
289                        action.trim()
290                    ),
291                });
292                pattern.trim().to_string()
293            } else {
294                line.to_string()
295            };
296
297            match CompatRegex::compile(&pattern) {
298                Ok(regex) => {
299                    let uses_fancy = regex.is_fancy();
300                    if uses_fancy {
301                        diagnostics.push(RuleDiagnostic {
302                            line_number: Some(line_number),
303                            severity: RuleSeverity::Info,
304                            message: format!(
305                                "pattern '{}' compiled with fancy_regex backend (Python-like features enabled)",
306                                pattern
307                            ),
308                        });
309                    }
310                    entries.push(PproxyRuleEntry {
311                        line_number,
312                        raw: pattern,
313                        regex,
314                        uses_fancy,
315                    });
316                }
317                Err(e) => {
318                    diagnostics.push(RuleDiagnostic {
319                        line_number: Some(line_number),
320                        severity: RuleSeverity::Error,
321                        message: format!(
322                            "line {}: failed to compile regex '{}': {}",
323                            line_number, pattern, e
324                        ),
325                    });
326                }
327            }
328        }
329
330        Ok(PproxyRuleFile {
331            path: path.to_path_buf(),
332            entries,
333            diagnostics,
334        })
335    }
336
337    /// Match a hostname against all rules in the file.
338    ///
339    /// Returns `true` if any rule matches the hostname (first-match-wins semantics).
340    pub fn matches_host(&self, hostname: &str) -> Result<bool, RegexMatchError> {
341        for entry in &self.entries {
342            if entry.regex.is_match(hostname)? {
343                return Ok(true);
344            }
345        }
346        Ok(false)
347    }
348
349    /// Return only the error-level diagnostics.
350    pub fn errors(&self) -> Vec<&RuleDiagnostic> {
351        self.diagnostics
352            .iter()
353            .filter(|d| d.severity == RuleSeverity::Error)
354            .collect()
355    }
356
357    /// Return true if there are any error-level diagnostics.
358    pub fn has_errors(&self) -> bool {
359        self.diagnostics
360            .iter()
361            .any(|d| d.severity == RuleSeverity::Error)
362    }
363}
364
365/// Compile a single block regex pattern (from `-b` flag).
366///
367/// Validates pattern length and compile-time correctness.
368pub fn compile_block_pattern(pattern: &str) -> Result<CompatRegex, RegexCompileError> {
369    CompatRegex::compile(pattern)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use std::io::Write;
376    use tempfile::NamedTempFile;
377
378    #[test]
379    fn compile_simple_pattern() {
380        let re = CompatRegex::compile(".*\\.example\\.com").unwrap();
381        assert!(re.is_match("www.example.com").unwrap());
382        assert!(!re.is_match("example.org").unwrap());
383        assert_eq!(re.backend(), RegexBackend::Fast);
384        assert!(!re.is_fancy());
385    }
386
387    #[test]
388    fn compile_lookahead_pattern() {
389        // Lookahead is not supported by regex crate, should fall back to fancy_regex
390        let re = CompatRegex::compile("(?=foo)foo").unwrap();
391        assert!(re.is_match("foo").unwrap());
392        assert!(!re.is_match("bar").unwrap());
393        assert_eq!(re.backend(), RegexBackend::Fancy);
394        assert!(re.is_fancy());
395    }
396
397    #[test]
398    fn compile_lookbehind_pattern() {
399        let re = CompatRegex::compile("(?<=foo)bar").unwrap();
400        assert!(re.is_match("foobar").unwrap());
401        assert!(!re.is_match("bazbar").unwrap());
402        assert_eq!(re.backend(), RegexBackend::Fancy);
403    }
404
405    #[test]
406    fn compile_backreference_pattern() {
407        // Backreferences are not supported by regex crate
408        let re = CompatRegex::compile(r"(.)\1").unwrap();
409        assert!(re.is_match("aa").unwrap());
410        assert!(!re.is_match("ab").unwrap());
411        assert_eq!(re.backend(), RegexBackend::Fancy);
412    }
413
414    #[test]
415    fn compile_invalid_pattern() {
416        let err = CompatRegex::compile("[invalid").unwrap_err();
417        match err {
418            RegexCompileError::CompileError { pattern, .. } => {
419                assert!(pattern.contains("[invalid"));
420            }
421            _ => panic!("expected CompileError"),
422        }
423    }
424
425    #[test]
426    fn compile_pattern_too_long() {
427        let pattern = "a".repeat(MAX_PATTERN_LEN + 1);
428        let err = CompatRegex::compile(&pattern).unwrap_err();
429        match err {
430            RegexCompileError::PatternTooLong { len, max } => {
431                assert_eq!(len, MAX_PATTERN_LEN + 1);
432                assert_eq!(max, MAX_PATTERN_LEN);
433            }
434            _ => panic!("expected PatternTooLong"),
435        }
436    }
437
438    #[test]
439    fn compile_pattern_at_length_boundary() {
440        let pattern = "a".repeat(MAX_PATTERN_LEN);
441        let re = CompatRegex::compile(&pattern).unwrap();
442        assert_eq!(re.as_str().len(), MAX_PATTERN_LEN);
443    }
444
445    #[test]
446    fn fancy_regex_backtrack_limit_exhaustion() {
447        // Pattern known to cause catastrophic backtracking with repeated alternation
448        // and a lookahead that forces the VM to try all combinations.
449        // Using a deliberately low limit through compile_fancy to prove limit enforcement.
450        use fancy_regex::RegexBuilder;
451        let low_limit = 100;
452        let re = RegexBuilder::new("(?i)(a|b|ab)*(?=c)")
453            .backtrack_limit(low_limit)
454            .build()
455            .unwrap();
456        // This input forces the backtracking engine to explore many paths
457        let result = re.is_match("abababababababababababababababababababababababababababab");
458        assert!(result.is_err(), "should fail with BacktrackLimitExceeded");
459        let err_msg = result.unwrap_err().to_string();
460        assert!(
461            err_msg.contains("backtrack")
462                || err_msg.contains("limit")
463                || err_msg.contains("Runtime"),
464            "error should be backtrack-limit related: {err_msg}"
465        );
466    }
467
468    #[test]
469    fn fancy_regex_explicit_limit_matches_default() {
470        // Verify the constant matches the fancy_regex 0.14 default.
471        use fancy_regex::RegexBuilder;
472        let default_re = RegexBuilder::new("(?=.*(\\d)\\1)").build().unwrap();
473        let explicit_re = RegexBuilder::new("(?=.*(\\d)\\1)")
474            .backtrack_limit(FANCY_REGEX_BACKTRACK_LIMIT)
475            .build()
476            .unwrap();
477        // Both should produce the same match result on normal input
478        let input = "a11b";
479        assert_eq!(
480            default_re.is_match(input).unwrap(),
481            explicit_re.is_match(input).unwrap(),
482            "explicit limit should match default behavior"
483        );
484    }
485
486    #[test]
487    fn fancy_regex_backtrack_limit_is_configured() {
488        // Verify the constant is set to the expected value
489        assert_eq!(
490            FANCY_REGEX_BACKTRACK_LIMIT, 1_000_000,
491            "FANCY_REGEX_BACKTRACK_LIMIT should be 1,000,000"
492        );
493    }
494
495    #[test]
496    fn compile_fancy_forces_fancy_backend() {
497        // Simple pattern that would normally use fast backend
498        let re = CompatRegex::compile_fancy(".*\\.com").unwrap();
499        assert_eq!(re.backend(), RegexBackend::Fancy);
500        assert!(re.is_fancy());
501        assert!(re.is_match("example.com").unwrap());
502    }
503
504    #[test]
505    fn compile_fancy_invalid_pattern() {
506        let err = CompatRegex::compile_fancy("[invalid").unwrap_err();
507        match err {
508            RegexCompileError::CompileError { .. } => {}
509            _ => panic!("expected CompileError"),
510        }
511    }
512
513    #[test]
514    fn rulefile_load_simple() {
515        let mut f = NamedTempFile::new().unwrap();
516        writeln!(f, "# comment line").unwrap();
517        writeln!(f).unwrap();
518        writeln!(f, ".*\\.example\\.com -> reject").unwrap();
519        writeln!(f, "ads\\.com -> block").unwrap();
520
521        let file = PproxyRuleFile::load(f.path()).unwrap();
522        assert_eq!(file.entries.len(), 2);
523        assert_eq!(file.entries[0].raw, ".*\\.example\\.com");
524        assert_eq!(file.entries[1].raw, "ads\\.com");
525        assert!(file.errors().is_empty());
526    }
527
528    #[test]
529    fn rulefile_load_with_lookahead() {
530        let mut f = NamedTempFile::new().unwrap();
531        writeln!(f, "(?=foo)foo -> reject").unwrap();
532
533        let file = PproxyRuleFile::load(f.path()).unwrap();
534        assert_eq!(file.entries.len(), 1);
535        assert!(file.entries[0].uses_fancy);
536        // Should have an info diagnostic about fancy backend
537        assert!(file
538            .diagnostics
539            .iter()
540            .any(|d| d.severity == RuleSeverity::Info));
541    }
542
543    #[test]
544    fn rulefile_load_invalid_regex() {
545        let mut f = NamedTempFile::new().unwrap();
546        writeln!(f, "[invalid -> reject").unwrap();
547
548        let file = PproxyRuleFile::load(f.path()).unwrap();
549        assert!(file.entries.is_empty());
550        assert!(file.has_errors());
551    }
552
553    #[test]
554    fn rulefile_load_partial_action() {
555        let mut f = NamedTempFile::new().unwrap();
556        writeln!(f, ".*\\.com -> allow").unwrap();
557
558        let file = PproxyRuleFile::load(f.path()).unwrap();
559        assert_eq!(file.entries.len(), 1);
560        assert!(file
561            .diagnostics
562            .iter()
563            .any(|d| d.severity == RuleSeverity::Warning));
564    }
565
566    #[test]
567    fn rulefile_load_unrecognized_format() {
568        let mut f = NamedTempFile::new().unwrap();
569        writeln!(f, "just a plain line").unwrap();
570
571        let file = PproxyRuleFile::load(f.path()).unwrap();
572        assert_eq!(file.entries.len(), 1);
573        assert!(file.diagnostics.is_empty());
574    }
575
576    #[test]
577    fn rulefile_matches_host() {
578        let mut f = NamedTempFile::new().unwrap();
579        writeln!(f, ".*\\.blocked\\.com -> reject").unwrap();
580        writeln!(f, "ads\\..* -> block").unwrap();
581
582        let file = PproxyRuleFile::load(f.path()).unwrap();
583        assert!(file.matches_host("www.blocked.com").unwrap());
584        assert!(file.matches_host("ads.example.com").unwrap());
585        assert!(!file.matches_host("safe.example.com").unwrap());
586    }
587
588    #[test]
589    fn rulefile_matches_first_wins() {
590        let mut f = NamedTempFile::new().unwrap();
591        writeln!(f, ".* -> reject").unwrap();
592        writeln!(f, "safe\\.com -> block").unwrap();
593
594        let file = PproxyRuleFile::load(f.path()).unwrap();
595        // First rule matches everything
596        assert!(file.matches_host("safe.com").unwrap());
597    }
598
599    #[test]
600    fn compile_block_pattern_simple() {
601        let re = compile_block_pattern(".*\\.ads\\.com").unwrap();
602        assert!(re.is_match("banner.ads.com").unwrap());
603        assert!(!re.is_match("clean.com").unwrap());
604    }
605
606    #[test]
607    fn rulefile_empty_file() {
608        let f = NamedTempFile::new().unwrap();
609        let file = PproxyRuleFile::load(f.path()).unwrap();
610        assert!(file.entries.is_empty());
611        assert!(!file.has_errors());
612    }
613
614    #[test]
615    fn regex_display_debug() {
616        let re = CompatRegex::compile("test").unwrap();
617        let debug = format!("{:?}", re);
618        assert!(debug.contains("CompatRegex::Fast"));
619        let display = format!("{}", re.backend());
620        assert_eq!(display, "fast");
621    }
622
623    #[test]
624    fn rule_diagnostic_display() {
625        let diag = RuleDiagnostic {
626            line_number: Some(5),
627            severity: RuleSeverity::Error,
628            message: "bad pattern".to_string(),
629        };
630        assert_eq!(diag.severity.to_string(), "error");
631        assert_eq!(diag.line_number, Some(5));
632        assert_eq!(diag.message, "bad pattern");
633    }
634
635    #[test]
636    fn regex_compile_error_display() {
637        let err = RegexCompileError::PatternTooLong {
638            len: 5000,
639            max: 4096,
640        };
641        let s = err.to_string();
642        assert!(s.contains("5000"));
643        assert!(s.contains("4096"));
644
645        let err = RegexCompileError::CompileError {
646            pattern: "bad".to_string(),
647            message: "syntax error".to_string(),
648        };
649        let s = err.to_string();
650        assert!(s.contains("bad"));
651        assert!(s.contains("syntax error"));
652    }
653
654    #[test]
655    fn fancy_regex_python_conditional() {
656        // Python re supports conditionals (?(id/name)yes-pattern|no-pattern)
657        // fancy_regex also supports this construct
658        let re = CompatRegex::compile("(?(foo)yes|no)").unwrap();
659        assert_eq!(re.backend(), RegexBackend::Fancy);
660        // The conditional checks if capture group "foo" matched
661        // Without a preceding match, it takes the "no" branch
662        assert!(re.is_match("no").unwrap());
663    }
664
665    #[test]
666    fn fancy_regex_atomic_group() {
667        // Python 3.11+ supports atomic groups (?>...)
668        // fancy_regex also supports this construct
669        let re = CompatRegex::compile("(?>foo)").unwrap();
670        assert!(re.is_match("foo").unwrap());
671        // Atomic group matches "foo" at start of "foobar" (not anchored to end)
672    }
673
674    #[test]
675    fn regex_unicode_category() {
676        // Python re supports \p{Letter} Unicode categories
677        // The fast regex crate also supports this
678        let re = CompatRegex::compile("\\p{Letter}").unwrap();
679        assert!(re.is_match("a").unwrap());
680        assert!(re.is_match("Z").unwrap());
681        assert!(!re.is_match("1").unwrap());
682        assert_eq!(re.backend(), RegexBackend::Fast);
683    }
684
685    #[test]
686    fn fancy_regex_backreference_in_lookahead() {
687        // Backreference inside a lookahead — compiles and runs in fancy_regex
688        let re = CompatRegex::compile(r"(?=.*(\d)\1)").unwrap();
689        // Matches strings containing a repeated digit (e.g., "11", "22")
690        assert!(re.is_match("a11b").unwrap());
691        assert!(!re.is_match("abc").unwrap());
692    }
693
694    #[test]
695    fn fancy_regex_backreference_matches_correctly() {
696        // Verify that simple backreferences work as expected
697        let re = CompatRegex::compile(r"(\w+)\s+\1").unwrap();
698        assert!(re.is_match("the the").unwrap());
699        assert!(!re.is_match("the that").unwrap());
700        assert_eq!(re.backend(), RegexBackend::Fancy);
701    }
702
703    #[test]
704    fn fancy_regex_lookahead_lookbehind_combined() {
705        // Combined lookahead and lookbehind
706        let re = CompatRegex::compile(r"(?<=@)\w+(?=\.com)").unwrap();
707        assert!(re.is_match("user@example.com").unwrap());
708        assert!(!re.is_match("user@example.org").unwrap());
709        assert_eq!(re.backend(), RegexBackend::Fancy);
710    }
711
712    #[test]
713    fn rulefile_max_entries_enforced() {
714        let mut f = NamedTempFile::new().unwrap();
715        // Write MAX_RULE_ENTRIES valid patterns plus one extra
716        for i in 0..=MAX_RULE_ENTRIES {
717            writeln!(f, "pattern_{i}").unwrap();
718        }
719        f.flush().unwrap();
720
721        let file = PproxyRuleFile::load(f.path()).unwrap();
722        // Should have loaded exactly MAX_RULE_ENTRIES entries (the extra one is dropped)
723        assert_eq!(file.entries.len(), MAX_RULE_ENTRIES);
724        // Should have an error diagnostic about exceeding the limit
725        assert!(file.has_errors());
726        let err_diag = file
727            .diagnostics
728            .iter()
729            .find(|d| d.severity == RuleSeverity::Error)
730            .expect("should have an error diagnostic");
731        assert!(
732            err_diag.message.contains("exceeds maximum"),
733            "diagnostic should mention exceeding max: {}",
734            err_diag.message
735        );
736    }
737}