From 963c13f80d80dcff748e57061b18b542ba76a463 Mon Sep 17 00:00:00 2001 From: Gonne Date: Mon, 2 Dec 2024 20:52:32 +0100 Subject: [PATCH] Some basic tests --- .gitignore | 2 +- src/lib.rs | 183 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 150 ++------------------------------ testdata/simple.aliases | 4 + 4 files changed, 193 insertions(+), 146 deletions(-) create mode 100644 src/lib.rs create mode 100644 testdata/simple.aliases diff --git a/.gitignore b/.gitignore index 532d706..44a5880 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,7 @@ testdata/virt_aliases # Added by cargo /target - +tarpaulin-report.html # Nix /result diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1cb6577 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,183 @@ +use email_address_parser::EmailAddress; +use fqdn::FQDN; +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::error::Error; +use std::fs::File; +use std::io::{self, BufRead}; +use std::path::Path; + +pub struct AliasFile { + pub content: io::Lines>, + pub default_domain: FQDN, +} + +#[derive(PartialEq, Eq, Clone)] +pub struct OrdEmailAddress(EmailAddress); + +impl PartialOrd for OrdEmailAddress { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.0.to_string().cmp(&other.0.to_string())) + } +} + +impl Ord for OrdEmailAddress { + fn cmp(&self, other: &Self) -> Ordering { + self.0.to_string().cmp(&other.0.to_string()) + } +} + +pub type AliasMap = BTreeMap>; + +/// Read a virtual alias file +/// and convert it to a map of destination addresses to a list of their final forwarding addresses. +pub fn parse_alias_to_map(alias_files: Vec) -> Result> { + // File must exist in the current path + let mut redirect_map: AliasMap = AliasMap::new(); + let mut destinations: Vec = Vec::new(); + + // Extract all pairs (destination to redirect addresses) from the alias files + for alias_file in alias_files { + for line in alias_file.content { + // Ignore comments in the alias file + let line = line?; + let line = String::from(line.split_at(line.find('#').unwrap_or(line.len())).0); + let destination = line.split_at(line.find(char::is_whitespace).unwrap_or(0)).0; + + if destination.is_empty() { + continue; + } + + let redirects: Vec = line + .split_at(line.find(char::is_whitespace).unwrap_or(0)) + .1 + .split(' ') + .filter(|address| !address.trim().to_string().replace(',', "").is_empty()) + .map(|addr| to_mailaddress(addr, &alias_file.default_domain)) + .collect::, _>>()?; + + if redirects.is_empty() { + continue; + } + destinations.push(to_mailaddress(destination, &alias_file.default_domain)?); + redirect_map.insert( + to_mailaddress(destination, &alias_file.default_domain)?, + redirects, + ); + } + } + + // Replace redirects that are again forwarded elsewhere by that. + // Break after depth max_iterations and assume infinite recursion afterwards. + let mut changed = true; + let mut iterations = 0; + let max_iterations = 100; + while changed && iterations < max_iterations { + changed = false; + iterations += 1; + let mut all_new_redirects: AliasMap = AliasMap::new(); + for destination in &destinations { + for forward_to in redirect_map.get(destination).unwrap() { + if let Some(new_redirects) = redirect_map.get(forward_to) { + changed = true; + all_new_redirects + .entry(destination.clone()) + .or_insert(redirect_map.get(destination).unwrap().clone()) + .retain(|dest| *dest != *forward_to); + all_new_redirects + .entry(destination.clone()) + .and_modify(|d| d.extend(new_redirects.iter().cloned())); + } + } + } + for (destination, new_redirect) in all_new_redirects { + *redirect_map.get_mut(&destination).unwrap() = new_redirect; + } + } + if iterations == max_iterations { + return Err(String::from("Possibly infinite recursion detected in parse_alias_map. Did not terminate after {max_iterations} rounds.").into()); + } + Ok(redirect_map) +} + +/// Create an `OrdEmailAddress` from some alias entry. +/// Return parameter for complete mail addresses and append the default domain for local parts. +fn to_mailaddress( + alias_entry: &str, + default_domain: &FQDN, +) -> Result> { + let mut addr = alias_entry.trim().to_string(); + addr = addr.replace(',', ""); + if addr.contains('@') { + return Ok(OrdEmailAddress( + EmailAddress::parse(&addr, None) + .ok_or::>(String::from("Mailaddress {addr} not parsable.").into())?, + )); + } + let unsortable_mail = EmailAddress::new(&addr, &default_domain.to_string(), None)?; + Ok(OrdEmailAddress(unsortable_mail)) +} + +// The output is wrapped in a Result to allow matching on errors. +// Returns an Iterator to the Reader of the lines of the file. +pub fn read_lines

(filename: P) -> io::Result>> +where + P: AsRef, +{ + let file = File::open(filename)?; + Ok(io::BufReader::new(file).lines()) +} + +/// Generate a Sieve script +/// from a map of destination addresses to a list of their forwarding addresses. +/// +/// Addresses are sorted according to the order on `OrdEmailAddress`. +pub fn generate_sieve_script(redirects: AliasMap) -> String { + let mut script: String = "require [\"envelope\", \"copy\"];\n\n".to_string(); + for (redirect, mut destinations) in redirects { + script += format!("if envelope :is \"to\" \"{}\" {{\n{}}}\n", redirect.0, { + let mut subscript: String = String::new(); + destinations.sort(); + for destination in destinations.iter().rev().skip(1).rev() { + subscript += format!(" redirect :copy \"{}\";\n", destination.0).as_str(); + } + subscript + + format!( + " redirect \"{}\";\n", + destinations.iter().next_back().unwrap().0 + ) + .as_str() + }) + .as_str(); + } + script +} + + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn recursion_detection() { + let result = parse_alias_to_map(vec![AliasFile { + content: read_lines("testdata/infiniterec.aliases").unwrap(), + default_domain: FQDN::from_str("example.com").unwrap(), + }]); + assert!(result.is_err()); + } + + #[test] + fn basic_parsing() { + let result = parse_alias_to_map(vec![AliasFile { + content: read_lines("testdata/simple.aliases").unwrap(), + default_domain: FQDN::from_str("example.com").unwrap(), + }]).unwrap(); + assert_eq!(result.len(), 4); + + for redirects in result.iter() { + assert_eq!(redirects.1[0].0.to_string(), "me@example.org"); + } + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 512efd0..82b2cd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,8 @@ -use email_address_parser::EmailAddress; +use alias_to_sieve::*; use fqdn::FQDN; -use std::cmp::Ordering; -use std::collections::BTreeMap; use std::env; -use std::fs::File; -use std::io::{self, BufRead}; -use std::path::Path; use std::str::FromStr; -struct AliasFile { - content: io::Lines>, - default_domain: FQDN, -} - -#[derive(PartialEq, Eq, Clone)] -struct OrdEmailAddress(EmailAddress); - -impl PartialOrd for OrdEmailAddress { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.0.to_string().cmp(&other.0.to_string())) - } -} - -impl Ord for OrdEmailAddress { - fn cmp(&self, other: &Self) -> Ordering { - self.0.to_string().cmp(&other.0.to_string()) - } -} - -type AliasMap = BTreeMap>; - fn main() { let args: Vec = env::args().collect(); if args.len() < 2 || args.len() % 2 == 0 { @@ -47,123 +20,10 @@ fn main() { }); } } - println!("{}", generate_sieve_script(parse_alias_to_map(alias_files))); -} - -/// Read a virtual alias file -/// and convert it to a map of destination addresses to a list of their final forwarding addresses. -fn parse_alias_to_map(alias_files: Vec) -> AliasMap { - // File must exist in the current path - let mut redirect_map: AliasMap = AliasMap::new(); - let mut destinations: Vec = Vec::new(); - - // Extract all pairs (destination to redirect addresses) from the alias files - for alias_file in alias_files { - for line in alias_file.content { - let line = line.unwrap(); - - // Ignore comments in the alias file - let line = String::from(line.split_at(line.find('#').unwrap_or(line.len())).0); - let destination = line.split_at(line.find(char::is_whitespace).unwrap_or(0)).0; - - if destination.is_empty() { - continue; - } - - let redirects: Vec = line - .split_at(line.find(char::is_whitespace).unwrap_or(0)) - .1 - .split(' ') - .filter(|address| !address.trim().to_string().replace(',', "").is_empty()) - .map(|addr| to_mailaddress(addr, &alias_file.default_domain)) - .collect(); - - if redirects.is_empty() { - continue; - } - destinations.push(to_mailaddress(destination, &alias_file.default_domain)); - redirect_map.insert( - to_mailaddress(destination, &alias_file.default_domain), - redirects, - ); - } - } - - // Replace redirects that are again forwarded elsewhere by that. - // Break after depth max_iterations and assume infinite recursion afterwards. - let mut changed = true; - let mut iterations = 0; - let max_iterations = 100; - while changed && iterations < max_iterations { - changed = false; - iterations += 1; - let mut all_new_redirects: AliasMap = AliasMap::new(); - for destination in &destinations { - for forward_to in redirect_map.get(destination).unwrap() { - if let Some(new_redirects) = redirect_map.get(forward_to) { - changed = true; - all_new_redirects - .entry(destination.clone()) - .or_insert(redirect_map.get(destination).unwrap().clone()) - .retain(|dest| *dest != *forward_to); - all_new_redirects - .entry(destination.clone()) - .and_modify(|d| d.extend(new_redirects.iter().cloned())); - } - } - } - for (destination, new_redirect) in all_new_redirects { - *redirect_map.get_mut(&destination).unwrap() = new_redirect; - } - } - assert!(iterations != max_iterations, "Possibly infinite recursion detected in parse_alias_map. Did not terminate after {max_iterations} rounds."); - redirect_map -} - -/// Generate a Sieve script -/// from a map of destination addresses to a list of their forwarding addresses. -/// -/// Addresses are sorted according to the order on `OrdEmailAddress`. -fn generate_sieve_script(redirects: AliasMap) -> String { - let mut script: String = "require [\"envelope\", \"copy\"];\n\n".to_string(); - for (redirect, mut destinations) in redirects { - script += format!("if envelope :is \"to\" \"{}\" {{\n{}}}\n", redirect.0, { - let mut subscript: String = String::new(); - destinations.sort(); - for destination in destinations.iter().rev().skip(1).rev() { - subscript += format!(" redirect :copy \"{}\";\n", destination.0).as_str(); - } - subscript - + format!( - " redirect \"{}\";\n", - destinations.iter().next_back().unwrap().0 - ) - .as_str() - }) - .as_str(); - } - script -} - -/// Create an `OrdEmailAddress` from some alias entry. -/// Return parameter for complete mail addresses and append the default domain for local parts. -fn to_mailaddress(alias_entry: &str, default_domain: &FQDN) -> OrdEmailAddress { - let mut addr = alias_entry.trim().to_string(); - addr = addr.replace(',', ""); - if addr.contains('@') { - return OrdEmailAddress(EmailAddress::parse(&addr, None).unwrap()); - } - OrdEmailAddress(EmailAddress::new(&addr, &default_domain.to_string(), None).unwrap()) -} - -// The output is wrapped in a Result to allow matching on errors. -// Returns an Iterator to the Reader of the lines of the file. -fn read_lines

(filename: P) -> io::Result>> -where - P: AsRef, -{ - let file = File::open(filename)?; - Ok(io::BufReader::new(file).lines()) + println!( + "{}", + generate_sieve_script(parse_alias_to_map(alias_files).unwrap()) + ); } fn print_help() { diff --git a/testdata/simple.aliases b/testdata/simple.aliases new file mode 100644 index 0000000..690f620 --- /dev/null +++ b/testdata/simple.aliases @@ -0,0 +1,4 @@ +admin root +sudo root +postmaster admin +root me@example.org