use std::fmt;
use std::path::{Path, PathBuf};
const MAX_PATTERN_LEN: usize = 4096;
const MAX_RULE_ENTRIES: usize = 10_000;
const FANCY_REGEX_BACKTRACK_LIMIT: usize = 1_000_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RegexBackend {
Fast,
Fancy,
}
impl fmt::Display for RegexBackend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Fast => f.write_str("fast"),
Self::Fancy => f.write_str("fancy"),
}
}
}
#[derive(Clone)]
pub enum CompatRegex {
Fast(regex::Regex),
Fancy(fancy_regex::Regex),
}
impl fmt::Debug for CompatRegex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Fast(r) => write!(f, "CompatRegex::Fast({})", r.as_str()),
Self::Fancy(r) => write!(f, "CompatRegex::Fancy({})", r.as_str()),
}
}
}
impl CompatRegex {
pub fn compile(pattern: &str) -> Result<Self, RegexCompileError> {
if pattern.len() > MAX_PATTERN_LEN {
return Err(RegexCompileError::PatternTooLong {
len: pattern.len(),
max: MAX_PATTERN_LEN,
});
}
match regex::Regex::new(pattern) {
Ok(r) => Ok(Self::Fast(r)),
Err(_) => {
match fancy_regex::RegexBuilder::new(pattern)
.backtrack_limit(FANCY_REGEX_BACKTRACK_LIMIT)
.build()
{
Ok(r) => Ok(Self::Fancy(r)),
Err(e) => Err(RegexCompileError::CompileError {
pattern: pattern.to_string(),
message: e.to_string(),
}),
}
}
}
}
pub fn compile_fancy(pattern: &str) -> Result<Self, RegexCompileError> {
if pattern.len() > MAX_PATTERN_LEN {
return Err(RegexCompileError::PatternTooLong {
len: pattern.len(),
max: MAX_PATTERN_LEN,
});
}
match fancy_regex::RegexBuilder::new(pattern)
.backtrack_limit(FANCY_REGEX_BACKTRACK_LIMIT)
.build()
{
Ok(r) => Ok(Self::Fancy(r)),
Err(e) => Err(RegexCompileError::CompileError {
pattern: pattern.to_string(),
message: e.to_string(),
}),
}
}
pub fn is_match(&self, text: &str) -> Result<bool, RegexMatchError> {
match self {
Self::Fast(r) => Ok(r.is_match(text)),
Self::Fancy(r) => r.is_match(text).map_err(|e| RegexMatchError {
pattern: r.as_str().to_string(),
message: e.to_string(),
}),
}
}
pub fn backend(&self) -> RegexBackend {
match self {
Self::Fast(_) => RegexBackend::Fast,
Self::Fancy(_) => RegexBackend::Fancy,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Fast(r) => r.as_str(),
Self::Fancy(r) => r.as_str(),
}
}
pub fn is_fancy(&self) -> bool {
matches!(self, Self::Fancy(_))
}
}
#[derive(Debug, Clone)]
pub enum RegexCompileError {
PatternTooLong { len: usize, max: usize },
CompileError { pattern: String, message: String },
}
impl fmt::Display for RegexCompileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PatternTooLong { len, max } => {
write!(f, "pattern too long: {} bytes (max {})", len, max)
}
Self::CompileError { pattern, message } => {
write!(f, "failed to compile regex '{}': {}", pattern, message)
}
}
}
}
impl std::error::Error for RegexCompileError {}
#[derive(Debug, Clone)]
pub struct RegexMatchError {
pub pattern: String,
pub message: String,
}
impl fmt::Display for RegexMatchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"regex match failed for '{}': {}",
self.pattern, self.message
)
}
}
impl std::error::Error for RegexMatchError {}
#[derive(Debug, Clone)]
pub struct RuleDiagnostic {
pub line_number: Option<usize>,
pub severity: RuleSeverity,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RuleSeverity {
Info,
Warning,
Error,
}
impl fmt::Display for RuleSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Info => f.write_str("info"),
Self::Warning => f.write_str("warning"),
Self::Error => f.write_str("error"),
}
}
}
#[derive(Debug, Clone)]
pub struct PproxyRuleEntry {
pub line_number: usize,
pub raw: String,
pub regex: CompatRegex,
pub uses_fancy: bool,
}
#[derive(Debug)]
pub struct PproxyRuleFile {
pub path: PathBuf,
pub entries: Vec<PproxyRuleEntry>,
pub diagnostics: Vec<RuleDiagnostic>,
}
impl PproxyRuleFile {
pub fn load(path: &Path) -> Result<Self, RegexCompileError> {
let content =
std::fs::read_to_string(path).map_err(|e| RegexCompileError::CompileError {
pattern: String::new(),
message: format!("failed to read '{}': {}", path.display(), e),
})?;
let mut entries = Vec::new();
let mut diagnostics = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
let line_number = line_num + 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
if entries.len() >= MAX_RULE_ENTRIES {
diagnostics.push(RuleDiagnostic {
line_number: Some(line_number),
severity: RuleSeverity::Error,
message: format!(
"rule file exceeds maximum of {} entries; remaining lines ignored",
MAX_RULE_ENTRIES
),
});
break;
}
let pattern = if let Some((pattern, action)) = line.split_once("->") {
diagnostics.push(RuleDiagnostic {
line_number: Some(line_number),
severity: RuleSeverity::Warning,
message: format!(
"line {}: action suffix '{}' is not part of pproxy's regex-line format; using pattern only",
line_number,
action.trim()
),
});
pattern.trim().to_string()
} else {
line.to_string()
};
match CompatRegex::compile(&pattern) {
Ok(regex) => {
let uses_fancy = regex.is_fancy();
if uses_fancy {
diagnostics.push(RuleDiagnostic {
line_number: Some(line_number),
severity: RuleSeverity::Info,
message: format!(
"pattern '{}' compiled with fancy_regex backend (Python-like features enabled)",
pattern
),
});
}
entries.push(PproxyRuleEntry {
line_number,
raw: pattern,
regex,
uses_fancy,
});
}
Err(e) => {
diagnostics.push(RuleDiagnostic {
line_number: Some(line_number),
severity: RuleSeverity::Error,
message: format!(
"line {}: failed to compile regex '{}': {}",
line_number, pattern, e
),
});
}
}
}
Ok(PproxyRuleFile {
path: path.to_path_buf(),
entries,
diagnostics,
})
}
pub fn matches_host(&self, hostname: &str) -> Result<bool, RegexMatchError> {
for entry in &self.entries {
if entry.regex.is_match(hostname)? {
return Ok(true);
}
}
Ok(false)
}
pub fn errors(&self) -> Vec<&RuleDiagnostic> {
self.diagnostics
.iter()
.filter(|d| d.severity == RuleSeverity::Error)
.collect()
}
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == RuleSeverity::Error)
}
}
pub fn compile_block_pattern(pattern: &str) -> Result<CompatRegex, RegexCompileError> {
CompatRegex::compile(pattern)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn compile_simple_pattern() {
let re = CompatRegex::compile(".*\\.example\\.com").unwrap();
assert!(re.is_match("www.example.com").unwrap());
assert!(!re.is_match("example.org").unwrap());
assert_eq!(re.backend(), RegexBackend::Fast);
assert!(!re.is_fancy());
}
#[test]
fn compile_lookahead_pattern() {
let re = CompatRegex::compile("(?=foo)foo").unwrap();
assert!(re.is_match("foo").unwrap());
assert!(!re.is_match("bar").unwrap());
assert_eq!(re.backend(), RegexBackend::Fancy);
assert!(re.is_fancy());
}
#[test]
fn compile_lookbehind_pattern() {
let re = CompatRegex::compile("(?<=foo)bar").unwrap();
assert!(re.is_match("foobar").unwrap());
assert!(!re.is_match("bazbar").unwrap());
assert_eq!(re.backend(), RegexBackend::Fancy);
}
#[test]
fn compile_backreference_pattern() {
let re = CompatRegex::compile(r"(.)\1").unwrap();
assert!(re.is_match("aa").unwrap());
assert!(!re.is_match("ab").unwrap());
assert_eq!(re.backend(), RegexBackend::Fancy);
}
#[test]
fn compile_invalid_pattern() {
let err = CompatRegex::compile("[invalid").unwrap_err();
match err {
RegexCompileError::CompileError { pattern, .. } => {
assert!(pattern.contains("[invalid"));
}
_ => panic!("expected CompileError"),
}
}
#[test]
fn compile_pattern_too_long() {
let pattern = "a".repeat(MAX_PATTERN_LEN + 1);
let err = CompatRegex::compile(&pattern).unwrap_err();
match err {
RegexCompileError::PatternTooLong { len, max } => {
assert_eq!(len, MAX_PATTERN_LEN + 1);
assert_eq!(max, MAX_PATTERN_LEN);
}
_ => panic!("expected PatternTooLong"),
}
}
#[test]
fn compile_pattern_at_length_boundary() {
let pattern = "a".repeat(MAX_PATTERN_LEN);
let re = CompatRegex::compile(&pattern).unwrap();
assert_eq!(re.as_str().len(), MAX_PATTERN_LEN);
}
#[test]
fn fancy_regex_backtrack_limit_exhaustion() {
use fancy_regex::RegexBuilder;
let low_limit = 100;
let re = RegexBuilder::new("(?i)(a|b|ab)*(?=c)")
.backtrack_limit(low_limit)
.build()
.unwrap();
let result = re.is_match("abababababababababababababababababababababababababababab");
assert!(result.is_err(), "should fail with BacktrackLimitExceeded");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("backtrack")
|| err_msg.contains("limit")
|| err_msg.contains("Runtime"),
"error should be backtrack-limit related: {err_msg}"
);
}
#[test]
fn fancy_regex_explicit_limit_matches_default() {
use fancy_regex::RegexBuilder;
let default_re = RegexBuilder::new("(?=.*(\\d)\\1)").build().unwrap();
let explicit_re = RegexBuilder::new("(?=.*(\\d)\\1)")
.backtrack_limit(FANCY_REGEX_BACKTRACK_LIMIT)
.build()
.unwrap();
let input = "a11b";
assert_eq!(
default_re.is_match(input).unwrap(),
explicit_re.is_match(input).unwrap(),
"explicit limit should match default behavior"
);
}
#[test]
fn fancy_regex_backtrack_limit_is_configured() {
assert_eq!(
FANCY_REGEX_BACKTRACK_LIMIT, 1_000_000,
"FANCY_REGEX_BACKTRACK_LIMIT should be 1,000,000"
);
}
#[test]
fn compile_fancy_forces_fancy_backend() {
let re = CompatRegex::compile_fancy(".*\\.com").unwrap();
assert_eq!(re.backend(), RegexBackend::Fancy);
assert!(re.is_fancy());
assert!(re.is_match("example.com").unwrap());
}
#[test]
fn compile_fancy_invalid_pattern() {
let err = CompatRegex::compile_fancy("[invalid").unwrap_err();
match err {
RegexCompileError::CompileError { .. } => {}
_ => panic!("expected CompileError"),
}
}
#[test]
fn rulefile_load_simple() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "# comment line").unwrap();
writeln!(f).unwrap();
writeln!(f, ".*\\.example\\.com -> reject").unwrap();
writeln!(f, "ads\\.com -> block").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert_eq!(file.entries.len(), 2);
assert_eq!(file.entries[0].raw, ".*\\.example\\.com");
assert_eq!(file.entries[1].raw, "ads\\.com");
assert!(file.errors().is_empty());
}
#[test]
fn rulefile_load_with_lookahead() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "(?=foo)foo -> reject").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert_eq!(file.entries.len(), 1);
assert!(file.entries[0].uses_fancy);
assert!(file
.diagnostics
.iter()
.any(|d| d.severity == RuleSeverity::Info));
}
#[test]
fn rulefile_load_invalid_regex() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "[invalid -> reject").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert!(file.entries.is_empty());
assert!(file.has_errors());
}
#[test]
fn rulefile_load_partial_action() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, ".*\\.com -> allow").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert_eq!(file.entries.len(), 1);
assert!(file
.diagnostics
.iter()
.any(|d| d.severity == RuleSeverity::Warning));
}
#[test]
fn rulefile_load_unrecognized_format() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, "just a plain line").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert_eq!(file.entries.len(), 1);
assert!(file.diagnostics.is_empty());
}
#[test]
fn rulefile_matches_host() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, ".*\\.blocked\\.com -> reject").unwrap();
writeln!(f, "ads\\..* -> block").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert!(file.matches_host("www.blocked.com").unwrap());
assert!(file.matches_host("ads.example.com").unwrap());
assert!(!file.matches_host("safe.example.com").unwrap());
}
#[test]
fn rulefile_matches_first_wins() {
let mut f = NamedTempFile::new().unwrap();
writeln!(f, ".* -> reject").unwrap();
writeln!(f, "safe\\.com -> block").unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert!(file.matches_host("safe.com").unwrap());
}
#[test]
fn compile_block_pattern_simple() {
let re = compile_block_pattern(".*\\.ads\\.com").unwrap();
assert!(re.is_match("banner.ads.com").unwrap());
assert!(!re.is_match("clean.com").unwrap());
}
#[test]
fn rulefile_empty_file() {
let f = NamedTempFile::new().unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert!(file.entries.is_empty());
assert!(!file.has_errors());
}
#[test]
fn regex_display_debug() {
let re = CompatRegex::compile("test").unwrap();
let debug = format!("{:?}", re);
assert!(debug.contains("CompatRegex::Fast"));
let display = format!("{}", re.backend());
assert_eq!(display, "fast");
}
#[test]
fn rule_diagnostic_display() {
let diag = RuleDiagnostic {
line_number: Some(5),
severity: RuleSeverity::Error,
message: "bad pattern".to_string(),
};
assert_eq!(diag.severity.to_string(), "error");
assert_eq!(diag.line_number, Some(5));
assert_eq!(diag.message, "bad pattern");
}
#[test]
fn regex_compile_error_display() {
let err = RegexCompileError::PatternTooLong {
len: 5000,
max: 4096,
};
let s = err.to_string();
assert!(s.contains("5000"));
assert!(s.contains("4096"));
let err = RegexCompileError::CompileError {
pattern: "bad".to_string(),
message: "syntax error".to_string(),
};
let s = err.to_string();
assert!(s.contains("bad"));
assert!(s.contains("syntax error"));
}
#[test]
fn fancy_regex_python_conditional() {
let re = CompatRegex::compile("(?(foo)yes|no)").unwrap();
assert_eq!(re.backend(), RegexBackend::Fancy);
assert!(re.is_match("no").unwrap());
}
#[test]
fn fancy_regex_atomic_group() {
let re = CompatRegex::compile("(?>foo)").unwrap();
assert!(re.is_match("foo").unwrap());
}
#[test]
fn regex_unicode_category() {
let re = CompatRegex::compile("\\p{Letter}").unwrap();
assert!(re.is_match("a").unwrap());
assert!(re.is_match("Z").unwrap());
assert!(!re.is_match("1").unwrap());
assert_eq!(re.backend(), RegexBackend::Fast);
}
#[test]
fn fancy_regex_backreference_in_lookahead() {
let re = CompatRegex::compile(r"(?=.*(\d)\1)").unwrap();
assert!(re.is_match("a11b").unwrap());
assert!(!re.is_match("abc").unwrap());
}
#[test]
fn fancy_regex_backreference_matches_correctly() {
let re = CompatRegex::compile(r"(\w+)\s+\1").unwrap();
assert!(re.is_match("the the").unwrap());
assert!(!re.is_match("the that").unwrap());
assert_eq!(re.backend(), RegexBackend::Fancy);
}
#[test]
fn fancy_regex_lookahead_lookbehind_combined() {
let re = CompatRegex::compile(r"(?<=@)\w+(?=\.com)").unwrap();
assert!(re.is_match("user@example.com").unwrap());
assert!(!re.is_match("user@example.org").unwrap());
assert_eq!(re.backend(), RegexBackend::Fancy);
}
#[test]
fn rulefile_max_entries_enforced() {
let mut f = NamedTempFile::new().unwrap();
for i in 0..=MAX_RULE_ENTRIES {
writeln!(f, "pattern_{i}").unwrap();
}
f.flush().unwrap();
let file = PproxyRuleFile::load(f.path()).unwrap();
assert_eq!(file.entries.len(), MAX_RULE_ENTRIES);
assert!(file.has_errors());
let err_diag = file
.diagnostics
.iter()
.find(|d| d.severity == RuleSeverity::Error)
.expect("should have an error diagnostic");
assert!(
err_diag.message.contains("exceeds maximum"),
"diagnostic should mention exceeding max: {}",
err_diag.message
);
}
}