snolc / src/routing.rs

github - master - back to tree - upstream
use std::fs; use std::net::IpAddr; use std::path::Path; use ipnet::IpNet; use serde::Deserialize; use crate::error::{Error, Result}; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Action { Direct, Proxy, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(tag = "match", rename_all = "lowercase", deny_unknown_fields)] pub enum Rule { Domain { value: String, action: Action }, Ip { value: IpNet, action: Action }, Any { action: Action }, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[serde(deny_unknown_fields)] pub struct RuleSet { pub rules: Vec, } impl RuleSet { pub fn load(path: &Path) -> Result { let source = fs::read_to_string(path)?; let mut rules: Self = yaml_serde::from_str(&source).map_err(|error| Error::Routing(error.to_string()))?; rules.normalize_and_validate()?; Ok(rules) } pub fn normalize_and_validate(&mut self) -> Result<()> { if self.rules.is_empty() { return Err(Error::Routing("routing rules cannot be empty".to_owned())); } let last = self.rules.len() - 1; for (index, rule) in self.rules.iter_mut().enumerate() { match rule { Rule::Domain { value, .. } => { *value = normalize_domain(value)?; } Rule::Any { .. } if index != last => { return Err(Error::Routing("any rule must be last".to_owned())); } _ => {} } } if !matches!(self.rules.last(), Some(Rule::Any { .. })) { return Err(Error::Routing( "routing requires a final any rule".to_owned(), )); } Ok(()) } pub fn decide(&self, domain: Option<&str>, ip: Option) -> Result { let domain = domain.map(normalize_domain).transpose()?; for rule in &self.rules { match rule { Rule::Domain { value, action } if domain .as_deref() .is_some_and(|domain| domain_matches(domain, value)) => { return Ok(*action); } Rule::Ip { value, action } if ip.is_some_and(|ip| value.contains(&ip)) => { return Ok(*action); } Rule::Any { action } => return Ok(*action), _ => {} } } Err(Error::Routing("no routing rule matched".to_owned())) } } fn normalize_domain(value: &str) -> Result { let value = value .strip_suffix('.') .unwrap_or(value) .to_ascii_lowercase(); if value.is_empty() || value.len() > 253 { return Err(Error::Routing("invalid domain rule".to_owned())); } for label in value.split('.') { if label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-') || !label .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') { return Err(Error::Routing("invalid domain rule".to_owned())); } } Ok(value) } fn domain_matches(domain: &str, rule: &str) -> bool { domain == rule || domain .strip_suffix(rule) .is_some_and(|prefix| prefix.ends_with('.')) } #[cfg(test)] mod tests { use super::*; fn rules() -> RuleSet { let mut rules = RuleSet { rules: vec![ Rule::Domain { value: "Example.RU.".to_owned(), action: Action::Direct, }, Rule::Ip { value: "10.0.0.0/8".parse().unwrap(), action: Action::Direct, }, Rule::Any { action: Action::Proxy, }, ], }; rules.normalize_and_validate().unwrap(); rules } #[test] fn first_matching_rule_wins() { let rules = rules(); assert_eq!( rules .decide(Some("a.example.ru"), Some("8.8.8.8".parse().unwrap())) .unwrap(), Action::Direct ); assert_eq!( rules .decide(None, Some("10.2.3.4".parse().unwrap())) .unwrap(), Action::Direct ); assert_eq!( rules .decide(Some("notexample.ru"), Some("8.8.8.8".parse().unwrap())) .unwrap(), Action::Proxy ); } #[test] fn rejects_unreachable_rules_after_any() { let mut rules = RuleSet { rules: vec![ Rule::Any { action: Action::Proxy, }, Rule::Ip { value: "127.0.0.0/8".parse().unwrap(), action: Action::Direct, }, ], }; assert!(rules.normalize_and_validate().is_err()); } #[test] fn final_action_must_be_explicit() { let mut rules = RuleSet { rules: vec![Rule::Ip { value: "127.0.0.0/8".parse().unwrap(), action: Action::Direct, }], }; assert!(rules.normalize_and_validate().is_err()); } /// `snolc-geoconv`'s output (see `src/bin/geoconv.rs`) is a sequence of /// rule fragments meant to be pasted under an existing `rules:` key, /// plus a final `any` rule the converter deliberately leaves out. This /// checks that assembling them exactly that way -- indenting the /// converter's own output under `rules:`, no other edits -- produces a /// file this module accepts. #[test] fn geoconv_output_format_loads_after_the_documented_assembly_step() { let converter_output = "\ - match: domain value: example.com action: direct - match: ip value: 10.0.0.0/8 action: direct "; let mut source = "rules:\n".to_owned(); for line in converter_output.lines() { source.push_str(" "); source.push_str(line); source.push('\n'); } source.push_str(" - match: any\n action: proxy\n"); let mut rules: RuleSet = yaml_serde::from_str(&source).expect("geoconv output must parse as a RuleSet"); rules.normalize_and_validate().unwrap(); assert_eq!( rules.decide(Some("www.example.com"), None).unwrap(), Action::Direct ); assert_eq!( rules .decide(None, Some("10.1.2.3".parse().unwrap())) .unwrap(), Action::Direct ); assert_eq!( rules .decide(Some("other.example"), Some("8.8.8.8".parse().unwrap())) .unwrap(), Action::Proxy ); } }