use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use tracing::debug;
use crate::error::RulesError;
#[cfg(feature = "geoip")]
use crate::matcher::GeoipMatcher;
use crate::matcher::{CidrMatcher, DomainMatcher, KeywordMatcher};
use crate::rule::{Action, EngineRule, MatchContext, ParsedRule};
#[derive(Debug)]
pub enum MatchDecision<'a> {
Matched(&'a Action),
NeedIp,
}
#[derive(Debug)]
struct CompiledRuleSet {
domain_matcher: DomainMatcher,
keyword_matcher: Option<KeywordMatcher>,
cidr_matcher: CidrMatcher,
dst_ports: Vec<u16>,
src_cidr_matcher: CidrMatcher,
}
impl CompiledRuleSet {
fn compile(rules: Vec<ParsedRule>) -> Self {
let mut domain_matcher = DomainMatcher::new();
let mut keywords = Vec::new();
let mut v4_cidrs = Vec::new();
let mut v6_cidrs = Vec::new();
let mut dst_ports = Vec::new();
let mut src_v4_cidrs = Vec::new();
let mut src_v6_cidrs = Vec::new();
for rule in rules {
match rule {
ParsedRule::Domain(d) => domain_matcher.add_exact(&d),
ParsedRule::DomainSuffix(d) => domain_matcher.add_suffix(&d),
ParsedRule::DomainKeyword(k) => keywords.push(k),
ParsedRule::IpCidr(net) => match net {
ipnet::IpNet::V4(v4) => v4_cidrs.push(v4),
ipnet::IpNet::V6(v6) => v6_cidrs.push(v6),
},
ParsedRule::DstPort(port) => dst_ports.push(port),
ParsedRule::SrcIpCidr(net) => match net {
ipnet::IpNet::V4(v4) => src_v4_cidrs.push(v4),
ipnet::IpNet::V6(v6) => src_v6_cidrs.push(v6),
},
}
}
Self {
domain_matcher,
keyword_matcher: KeywordMatcher::new(keywords),
cidr_matcher: CidrMatcher::new(v4_cidrs, v6_cidrs),
dst_ports,
src_cidr_matcher: CidrMatcher::new(src_v4_cidrs, src_v6_cidrs),
}
}
fn matches(&self, ctx: &MatchContext) -> bool {
if let Some(domain) = ctx.domain {
if self.domain_matcher.matches(domain) {
return true;
}
if let Some(ref kw) = self.keyword_matcher
&& kw.matches(domain)
{
return true;
}
}
if let Some(ip) = ctx.dest_ip
&& self.cidr_matcher.contains(ip)
{
return true;
}
if !self.dst_ports.is_empty() && self.dst_ports.contains(&ctx.dest_port) {
return true;
}
if self.src_cidr_matcher.contains(ctx.src_ip) {
return true;
}
false
}
}
pub struct RuleEngine {
compiled_sets: HashMap<String, CompiledRuleSet>,
rules: Vec<EngineRule>,
final_action: Action,
#[cfg(feature = "geoip")]
geoip: Option<Arc<GeoipMatcher>>,
}
impl RuleEngine {
pub fn match_request(&self, ctx: &MatchContext) -> &Action {
for rule in &self.rules {
match rule {
EngineRule::RuleSet { name, action } => {
if let Some(compiled) = self.compiled_sets.get(name)
&& compiled.matches(ctx)
{
return action;
}
}
EngineRule::GeoIp { code, action } => {
#[cfg(feature = "geoip")]
if let Some(ref geoip) = self.geoip {
if let Some(ip) = ctx.dest_ip
&& geoip.matches(ip, code)
{
return action;
}
}
#[cfg(not(feature = "geoip"))]
{
let _ = (code, action);
}
}
EngineRule::Inline { rule, action } => {
if inline_matches(rule, ctx) {
return action;
}
}
EngineRule::Final { action } => {
return action;
}
}
}
&self.final_action
}
pub fn match_request_lazy_ip(&self, ctx: &MatchContext) -> MatchDecision<'_> {
for rule in &self.rules {
match rule {
EngineRule::RuleSet { name, action } => {
if let Some(compiled) = self.compiled_sets.get(name) {
if let Some(domain) = ctx.domain
&& (compiled.domain_matcher.matches(domain)
|| compiled
.keyword_matcher
.as_ref()
.is_some_and(|kw| kw.matches(domain)))
{
return MatchDecision::Matched(action);
}
if !compiled.dst_ports.is_empty()
&& compiled.dst_ports.contains(&ctx.dest_port)
{
return MatchDecision::Matched(action);
}
if compiled.src_cidr_matcher.contains(ctx.src_ip) {
return MatchDecision::Matched(action);
}
if ctx.dest_ip.is_none() && !compiled.cidr_matcher.is_empty() {
return MatchDecision::NeedIp;
}
if let Some(ip) = ctx.dest_ip
&& compiled.cidr_matcher.contains(ip)
{
return MatchDecision::Matched(action);
}
}
}
EngineRule::GeoIp { code, action } => {
#[cfg(feature = "geoip")]
if let Some(ref geoip) = self.geoip {
if ctx.dest_ip.is_none() {
return MatchDecision::NeedIp;
}
if let Some(ip) = ctx.dest_ip
&& geoip.matches(ip, code)
{
return MatchDecision::Matched(action);
}
}
#[cfg(not(feature = "geoip"))]
{
let _ = (code, action);
}
}
EngineRule::Inline { rule, action } => match rule {
ParsedRule::Domain(d) => {
if ctx
.domain
.is_some_and(|domain| domain.eq_ignore_ascii_case(d))
{
return MatchDecision::Matched(action);
}
}
ParsedRule::DomainSuffix(s) => {
if ctx.domain.is_some_and(|domain| {
let lower = domain.to_ascii_lowercase();
let suffix_lower = s.to_ascii_lowercase();
lower == suffix_lower || lower.ends_with(&format!(".{suffix_lower}"))
}) {
return MatchDecision::Matched(action);
}
}
ParsedRule::DomainKeyword(k) => {
if ctx.domain.is_some_and(|domain| {
domain
.to_ascii_lowercase()
.contains(&k.to_ascii_lowercase())
}) {
return MatchDecision::Matched(action);
}
}
ParsedRule::IpCidr(net) => {
if ctx.dest_ip.is_none() {
return MatchDecision::NeedIp;
}
if ctx.dest_ip.is_some_and(|ip| net.contains(&ip)) {
return MatchDecision::Matched(action);
}
}
ParsedRule::DstPort(port) => {
if ctx.dest_port == *port {
return MatchDecision::Matched(action);
}
}
ParsedRule::SrcIpCidr(net) => {
if net.contains(&ctx.src_ip) {
return MatchDecision::Matched(action);
}
}
},
EngineRule::Final { action } => {
return MatchDecision::Matched(action);
}
}
}
MatchDecision::Matched(&self.final_action)
}
pub fn has_ip_rules(&self) -> bool {
let has_cidr = self
.compiled_sets
.values()
.any(|cs| !cs.cidr_matcher.is_empty());
let has_geoip = {
#[cfg(feature = "geoip")]
{
self.geoip.is_some()
&& self
.rules
.iter()
.any(|r| matches!(r, EngineRule::GeoIp { .. }))
}
#[cfg(not(feature = "geoip"))]
{
false
}
};
has_cidr || has_geoip
}
pub fn rule_set_count(&self) -> usize {
self.compiled_sets.len()
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
}
fn inline_matches(rule: &ParsedRule, ctx: &MatchContext) -> bool {
match rule {
ParsedRule::Domain(d) => ctx
.domain
.is_some_and(|domain| domain.eq_ignore_ascii_case(d)),
ParsedRule::DomainSuffix(s) => ctx.domain.is_some_and(|domain| {
let lower = domain.to_ascii_lowercase();
let suffix_lower = s.to_ascii_lowercase();
lower == suffix_lower || lower.ends_with(&format!(".{suffix_lower}"))
}),
ParsedRule::DomainKeyword(k) => ctx.domain.is_some_and(|domain| {
domain
.to_ascii_lowercase()
.contains(&k.to_ascii_lowercase())
}),
ParsedRule::IpCidr(net) => ctx.dest_ip.is_some_and(|ip| net.contains(&ip)),
ParsedRule::DstPort(port) => ctx.dest_port == *port,
ParsedRule::SrcIpCidr(net) => net.contains(&ctx.src_ip),
}
}
impl std::fmt::Debug for RuleEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuleEngine")
.field("rule_sets", &self.compiled_sets.len())
.field("rules", &self.rules.len())
.field("final_action", &self.final_action)
.finish()
}
}
#[derive(Debug)]
pub struct RuleEngineBuilder {
rule_sets: HashMap<String, Vec<ParsedRule>>,
rules: Vec<EngineRule>,
final_action: Option<Action>,
#[cfg(feature = "geoip")]
geoip: Option<Arc<GeoipMatcher>>,
}
impl RuleEngineBuilder {
pub fn new() -> Self {
Self {
rule_sets: HashMap::new(),
rules: Vec::new(),
final_action: None,
#[cfg(feature = "geoip")]
geoip: None,
}
}
#[cfg(feature = "geoip")]
pub fn set_geoip(&mut self, matcher: Arc<GeoipMatcher>) -> &mut Self {
self.geoip = Some(matcher);
self
}
pub fn add_rule_set(&mut self, name: impl Into<String>, rules: Vec<ParsedRule>) -> &mut Self {
self.rule_sets.insert(name.into(), rules);
self
}
pub fn add_rule_set_rule(
&mut self,
rule_set_name: impl Into<String>,
action: Action,
) -> &mut Self {
self.rules.push(EngineRule::RuleSet {
name: rule_set_name.into(),
action,
});
self
}
pub fn add_geoip_rule(&mut self, code: impl Into<String>, action: Action) -> &mut Self {
self.rules.push(EngineRule::GeoIp {
code: code.into(),
action,
});
self
}
pub fn add_inline_rule(&mut self, rule: ParsedRule, action: Action) -> &mut Self {
self.rules.push(EngineRule::Inline { rule, action });
self
}
pub fn set_final(&mut self, action: Action) -> &mut Self {
self.final_action = Some(action.clone());
self.rules.push(EngineRule::Final { action });
self
}
pub fn build(self) -> Result<RuleEngine, RulesError> {
let final_action = self.final_action.ok_or(RulesError::NoFinalRule)?;
for rule in &self.rules {
if let EngineRule::RuleSet { name, .. } = rule
&& !self.rule_sets.contains_key(name)
{
return Err(RulesError::UnknownRuleSet(name.clone()));
}
}
{
let has_geoip_rules = self
.rules
.iter()
.any(|r| matches!(r, EngineRule::GeoIp { .. }));
if has_geoip_rules {
#[cfg(feature = "geoip")]
if self.geoip.is_none() {
tracing::warn!(
"GEOIP rules are configured but no GeoIP database is loaded; they will never match"
);
}
#[cfg(not(feature = "geoip"))]
tracing::warn!(
"GEOIP rules are configured but the 'geoip' feature is not enabled; they will never match"
);
}
}
let compiled_sets: HashMap<String, CompiledRuleSet> = self
.rule_sets
.into_iter()
.map(|(name, rules)| {
let count = rules.len();
let compiled = CompiledRuleSet::compile(rules);
debug!(
name = %name,
rules = count,
domains = compiled.domain_matcher.len(),
keywords = compiled.keyword_matcher.as_ref().map_or(0, |k| k.len()),
cidrs = compiled.cidr_matcher.len(),
"compiled rule-set"
);
(name, compiled)
})
.collect();
Ok(RuleEngine {
compiled_sets,
rules: self.rules,
final_action,
#[cfg(feature = "geoip")]
geoip: self.geoip,
})
}
}
impl Default for RuleEngineBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct HotRuleEngine {
inner: ArcSwap<RuleEngine>,
}
impl HotRuleEngine {
pub fn new(engine: RuleEngine) -> Self {
Self {
inner: ArcSwap::new(Arc::new(engine)),
}
}
pub fn match_request(&self, ctx: &MatchContext) -> Action {
let engine = self.inner.load();
engine.match_request(ctx).clone()
}
pub fn has_ip_rules(&self) -> bool {
self.inner.load().has_ip_rules()
}
pub fn update(&self, engine: RuleEngine) {
self.inner.store(Arc::new(engine));
}
pub fn match_request_lazy_ip(&self, ctx: &MatchContext) -> Option<Action> {
let engine = self.inner.load();
match engine.match_request_lazy_ip(ctx) {
MatchDecision::Matched(action) => Some(action.clone()),
MatchDecision::NeedIp => None,
}
}
pub fn rule_set_count(&self) -> usize {
self.inner.load().rule_set_count()
}
pub fn rule_count(&self) -> usize {
self.inner.load().rule_count()
}
}
impl std::fmt::Debug for HotRuleEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HotRuleEngine")
.field("inner", &*self.inner.load())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn ctx_domain(domain: &str) -> MatchContext<'_> {
MatchContext {
domain: Some(domain),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
}
}
fn ctx_ip(ip: IpAddr) -> MatchContext<'static> {
MatchContext {
domain: None,
dest_ip: Some(ip),
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
}
}
#[test]
fn rule_set_domain_match() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set(
"ads",
vec![
ParsedRule::DomainSuffix("ad.example.com".into()),
ParsedRule::DomainKeyword("ads".into()),
],
);
builder.add_rule_set_rule("ads", Action::Reject);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
assert_eq!(
engine.match_request(&ctx_domain("tracker.ad.example.com")),
&Action::Reject
);
assert_eq!(
engine.match_request(&ctx_domain("someads.com")),
&Action::Reject
);
assert_eq!(
engine.match_request(&ctx_domain("clean.example.com")),
&Action::Direct
);
}
#[test]
fn rule_set_ip_match() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set(
"private",
vec![ParsedRule::IpCidr("192.168.0.0/16".parse().unwrap())],
);
builder.add_rule_set_rule("private", Action::Outbound("vpn".into()));
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
assert_eq!(
engine.match_request(&ctx_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))),
&Action::Outbound("vpn".into())
);
assert_eq!(
engine.match_request(&ctx_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))),
&Action::Direct
);
}
#[test]
fn rule_order_matters() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set("block", vec![ParsedRule::Domain("example.com".into())]);
builder.add_rule_set(
"allow",
vec![ParsedRule::DomainSuffix("example.com".into())],
);
builder.add_rule_set_rule("block", Action::Reject);
builder.add_rule_set_rule("allow", Action::Direct);
builder.set_final(Action::Outbound("proxy".into()));
let engine = builder.build().unwrap();
assert_eq!(
engine.match_request(&ctx_domain("example.com")),
&Action::Reject
);
assert_eq!(
engine.match_request(&ctx_domain("sub.example.com")),
&Action::Direct
);
}
#[test]
fn final_action_catch_all() {
let mut builder = RuleEngineBuilder::new();
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
assert_eq!(
engine.match_request(&ctx_domain("anything.com")),
&Action::Direct
);
}
#[test]
fn no_final_rule_error() {
let builder = RuleEngineBuilder::new();
builder.build().unwrap_err();
}
#[test]
fn unknown_rule_set_error() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set_rule("nonexistent", Action::Reject);
builder.set_final(Action::Direct);
builder.build().unwrap_err();
}
#[test]
fn inline_rule_match() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(ParsedRule::Domain("blocked.com".into()), Action::Reject);
builder.add_inline_rule(
ParsedRule::IpCidr("10.0.0.0/8".parse().unwrap()),
Action::Outbound("internal".into()),
);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
assert_eq!(
engine.match_request(&ctx_domain("blocked.com")),
&Action::Reject
);
assert_eq!(
engine.match_request(&ctx_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))),
&Action::Outbound("internal".into())
);
assert_eq!(
engine.match_request(&ctx_domain("allowed.com")),
&Action::Direct
);
}
#[test]
fn engine_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<RuleEngine>();
}
#[test]
fn lazy_match_domain_before_ip_rule() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(ParsedRule::Domain("example.com".into()), Action::Reject);
builder.add_inline_rule(
ParsedRule::IpCidr("10.0.0.0/8".parse().unwrap()),
Action::Direct,
);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = ctx_domain("example.com");
match engine.match_request_lazy_ip(&ctx) {
MatchDecision::Matched(action) => assert_eq!(action, &Action::Reject),
MatchDecision::NeedIp => panic!("should not require IP for domain match"),
}
}
#[test]
fn lazy_match_needs_ip_when_ip_rule_first() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(
ParsedRule::IpCidr("10.0.0.0/8".parse().unwrap()),
Action::Reject,
);
builder.add_inline_rule(ParsedRule::Domain("example.com".into()), Action::Direct);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = ctx_domain("example.com");
match engine.match_request_lazy_ip(&ctx) {
MatchDecision::Matched(_) => panic!("should require IP before evaluating later rules"),
MatchDecision::NeedIp => {}
}
}
#[test]
fn geoip_skipped_when_dest_ip_none() {
let mut builder = RuleEngineBuilder::new();
builder.add_geoip_rule("CN", Action::Reject);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = ctx_domain("example.cn");
assert_eq!(engine.match_request(&ctx), &Action::Direct);
}
#[test]
fn inline_dst_port_match() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(ParsedRule::DstPort(80), Action::Reject);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 80,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
assert_eq!(engine.match_request(&ctx), &Action::Reject);
let ctx_miss = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
assert_eq!(engine.match_request(&ctx_miss), &Action::Direct);
}
#[test]
fn inline_src_ip_cidr_match() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(
ParsedRule::SrcIpCidr("10.0.0.0/8".parse().unwrap()),
Action::Reject,
);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx_match = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)),
};
assert_eq!(engine.match_request(&ctx_match), &Action::Reject);
let ctx_miss = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
};
assert_eq!(engine.match_request(&ctx_miss), &Action::Direct);
}
#[test]
fn rule_set_with_dst_port_and_src_cidr() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set(
"mixed",
vec![
ParsedRule::DomainSuffix("example.com".into()),
ParsedRule::DstPort(8080),
ParsedRule::SrcIpCidr("172.16.0.0/12".parse().unwrap()),
],
);
builder.add_rule_set_rule("mixed", Action::Outbound("proxy".into()));
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx_port = MatchContext {
domain: Some("other.com"),
dest_ip: None,
dest_port: 8080,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
assert_eq!(
engine.match_request(&ctx_port),
&Action::Outbound("proxy".into())
);
let ctx_src = MatchContext {
domain: Some("other.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::new(172, 16, 5, 1)),
};
assert_eq!(
engine.match_request(&ctx_src),
&Action::Outbound("proxy".into())
);
let ctx_miss = MatchContext {
domain: Some("other.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
assert_eq!(engine.match_request(&ctx_miss), &Action::Direct);
}
#[test]
fn lazy_match_dst_port_no_dns_needed() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(ParsedRule::DstPort(80), Action::Reject);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 80,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
match engine.match_request_lazy_ip(&ctx) {
MatchDecision::Matched(action) => assert_eq!(action, &Action::Reject),
MatchDecision::NeedIp => panic!("DST-PORT should not require IP resolution"),
}
}
#[test]
fn lazy_match_src_ip_cidr_no_dns_needed() {
let mut builder = RuleEngineBuilder::new();
builder.add_inline_rule(
ParsedRule::SrcIpCidr("10.0.0.0/8".parse().unwrap()),
Action::Reject,
);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
};
match engine.match_request_lazy_ip(&ctx) {
MatchDecision::Matched(action) => assert_eq!(action, &Action::Reject),
MatchDecision::NeedIp => panic!("SRC-IP-CIDR should not require IP resolution"),
}
}
#[test]
fn lazy_match_rule_set_port_before_cidr() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set(
"mixed",
vec![
ParsedRule::IpCidr("10.0.0.0/8".parse().unwrap()),
ParsedRule::DstPort(8080),
],
);
builder.add_rule_set_rule("mixed", Action::Reject);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
let ctx = MatchContext {
domain: Some("example.com"),
dest_ip: None,
dest_port: 8080,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
match engine.match_request_lazy_ip(&ctx) {
MatchDecision::Matched(action) => assert_eq!(action, &Action::Reject),
MatchDecision::NeedIp => {
panic!("DST-PORT in rule-set should match before CIDR triggers NeedIp")
}
}
let ctx_miss = MatchContext {
domain: Some("other.com"),
dest_ip: None,
dest_port: 443,
src_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
};
match engine.match_request_lazy_ip(&ctx_miss) {
MatchDecision::Matched(_) => {
panic!("should return NeedIp for unmatched port with CIDR")
}
MatchDecision::NeedIp => {}
}
}
#[test]
fn domain_suffix_leading_dot_in_rule_set() {
let mut builder = RuleEngineBuilder::new();
builder.add_rule_set(
"test",
vec![ParsedRule::DomainSuffix(".example.com".into())],
);
builder.add_rule_set_rule("test", Action::Reject);
builder.set_final(Action::Direct);
let engine = builder.build().unwrap();
assert_eq!(
engine.match_request(&ctx_domain("example.com")),
&Action::Reject
);
assert_eq!(
engine.match_request(&ctx_domain("sub.example.com")),
&Action::Reject
);
assert_eq!(
engine.match_request(&ctx_domain("other.com")),
&Action::Direct
);
}
}