Some basic tests
This commit is contained in:
parent
ab9a6f0d32
commit
963c13f80d
4 changed files with 193 additions and 146 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -30,7 +30,7 @@ testdata/virt_aliases
|
||||||
# Added by cargo
|
# Added by cargo
|
||||||
/target
|
/target
|
||||||
|
|
||||||
|
tarpaulin-report.html
|
||||||
|
|
||||||
# Nix
|
# Nix
|
||||||
/result
|
/result
|
||||||
|
|
183
src/lib.rs
Normal file
183
src/lib.rs
Normal file
|
@ -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<io::BufReader<File>>,
|
||||||
|
pub default_domain: FQDN,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone)]
|
||||||
|
pub struct OrdEmailAddress(EmailAddress);
|
||||||
|
|
||||||
|
impl PartialOrd for OrdEmailAddress {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
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<OrdEmailAddress, Vec<OrdEmailAddress>>;
|
||||||
|
|
||||||
|
/// Read a virtual alias file <https://www.postfix.org/virtual.5.html>
|
||||||
|
/// 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<AliasFile>) -> Result<AliasMap, Box<dyn Error>> {
|
||||||
|
// File must exist in the current path
|
||||||
|
let mut redirect_map: AliasMap = AliasMap::new();
|
||||||
|
let mut destinations: Vec<OrdEmailAddress> = 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<OrdEmailAddress> = 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::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
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<OrdEmailAddress, Box<dyn Error>> {
|
||||||
|
let mut addr = alias_entry.trim().to_string();
|
||||||
|
addr = addr.replace(',', "");
|
||||||
|
if addr.contains('@') {
|
||||||
|
return Ok(OrdEmailAddress(
|
||||||
|
EmailAddress::parse(&addr, None)
|
||||||
|
.ok_or::<Box<dyn Error>>(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<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
let file = File::open(filename)?;
|
||||||
|
Ok(io::BufReader::new(file).lines())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a Sieve script <https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)>
|
||||||
|
/// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
150
src/main.rs
150
src/main.rs
|
@ -1,35 +1,8 @@
|
||||||
use email_address_parser::EmailAddress;
|
use alias_to_sieve::*;
|
||||||
use fqdn::FQDN;
|
use fqdn::FQDN;
|
||||||
use std::cmp::Ordering;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs::File;
|
|
||||||
use std::io::{self, BufRead};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
struct AliasFile {
|
|
||||||
content: io::Lines<io::BufReader<File>>,
|
|
||||||
default_domain: FQDN,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Clone)]
|
|
||||||
struct OrdEmailAddress(EmailAddress);
|
|
||||||
|
|
||||||
impl PartialOrd for OrdEmailAddress {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
||||||
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<OrdEmailAddress, Vec<OrdEmailAddress>>;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
if args.len() < 2 || args.len() % 2 == 0 {
|
if args.len() < 2 || args.len() % 2 == 0 {
|
||||||
|
@ -47,123 +20,10 @@ fn main() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!("{}", generate_sieve_script(parse_alias_to_map(alias_files)));
|
println!(
|
||||||
}
|
"{}",
|
||||||
|
generate_sieve_script(parse_alias_to_map(alias_files).unwrap())
|
||||||
/// Read a virtual alias file <https://www.postfix.org/virtual.5.html>
|
);
|
||||||
/// 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<AliasFile>) -> AliasMap {
|
|
||||||
// File must exist in the current path
|
|
||||||
let mut redirect_map: AliasMap = AliasMap::new();
|
|
||||||
let mut destinations: Vec<OrdEmailAddress> = 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<OrdEmailAddress> = 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 <https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)>
|
|
||||||
/// 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<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
|
|
||||||
where
|
|
||||||
P: AsRef<Path>,
|
|
||||||
{
|
|
||||||
let file = File::open(filename)?;
|
|
||||||
Ok(io::BufReader::new(file).lines())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
|
|
4
testdata/simple.aliases
vendored
Normal file
4
testdata/simple.aliases
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
admin root
|
||||||
|
sudo root
|
||||||
|
postmaster admin
|
||||||
|
root me@example.org
|
Loading…
Reference in a new issue