1use std::fmt;
2use std::path::{Path, PathBuf};
3
4const MAX_PATTERN_LEN: usize = 4096;
6
7const MAX_RULE_ENTRIES: usize = 10_000;
9
10const FANCY_REGEX_BACKTRACK_LIMIT: usize = 1_000_000;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum RegexBackend {
18 Fast,
20 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#[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 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 match regex::Regex::new(pattern) {
68 Ok(r) => Ok(Self::Fast(r)),
69 Err(_) => {
70 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 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 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 pub fn backend(&self) -> RegexBackend {
119 match self {
120 Self::Fast(_) => RegexBackend::Fast,
121 Self::Fancy(_) => RegexBackend::Fancy,
122 }
123 }
124
125 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 pub fn is_fancy(&self) -> bool {
135 matches!(self, Self::Fancy(_))
136 }
137}
138
139#[derive(Debug, Clone)]
141pub enum RegexCompileError {
142 PatternTooLong { len: usize, max: usize },
144 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#[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#[derive(Debug, Clone)]
184pub struct RuleDiagnostic {
185 pub line_number: Option<usize>,
187 pub severity: RuleSeverity,
189 pub message: String,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
195pub enum RuleSeverity {
196 Info,
198 Warning,
200 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#[derive(Debug, Clone)]
216pub struct PproxyRuleEntry {
217 pub line_number: usize,
219 pub raw: String,
221 pub regex: CompatRegex,
223 pub uses_fancy: bool,
225}
226
227#[derive(Debug)]
229pub struct PproxyRuleFile {
230 pub path: PathBuf,
232 pub entries: Vec<PproxyRuleEntry>,
234 pub diagnostics: Vec<RuleDiagnostic>,
236}
237
238impl PproxyRuleFile {
239 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 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 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 pub fn errors(&self) -> Vec<&RuleDiagnostic> {
351 self.diagnostics
352 .iter()
353 .filter(|d| d.severity == RuleSeverity::Error)
354 .collect()
355 }
356
357 pub fn has_errors(&self) -> bool {
359 self.diagnostics
360 .iter()
361 .any(|d| d.severity == RuleSeverity::Error)
362 }
363}
364
365pub 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 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 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 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 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 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 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 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 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 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 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 let re = CompatRegex::compile("(?(foo)yes|no)").unwrap();
659 assert_eq!(re.backend(), RegexBackend::Fancy);
660 assert!(re.is_match("no").unwrap());
663 }
664
665 #[test]
666 fn fancy_regex_atomic_group() {
667 let re = CompatRegex::compile("(?>foo)").unwrap();
670 assert!(re.is_match("foo").unwrap());
671 }
673
674 #[test]
675 fn regex_unicode_category() {
676 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 let re = CompatRegex::compile(r"(?=.*(\d)\1)").unwrap();
689 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 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 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 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 assert_eq!(file.entries.len(), MAX_RULE_ENTRIES);
724 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}