From 5eafc0678631c97440dd3b963780a93863b408ba Mon Sep 17 00:00:00 2001 From: Mauro D Date: Wed, 7 Dec 2022 17:13:14 +0000 Subject: [PATCH] TOML parser --- .gitignore | 4 - Cargo.lock | 63 ++++ Cargo.toml | 9 + resources/config/config.toml | 213 +++++++++++++ src/config/mod.rs | 11 + src/config/parser.rs | 582 +++++++++++++++++++++++++++++++++++ src/config/utils.rs | 112 +++++++ src/main.rs | 5 + 8 files changed, 995 insertions(+), 4 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 resources/config/config.toml create mode 100644 src/config/mod.rs create mode 100644 src/config/parser.rs create mode 100644 src/config/utils.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 088ba6b..f2e972d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,5 @@ # will have compiled files and executables /target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d88f843 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,63 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6ccdb167abbf410dcb915cabd428929d7f6a04980b54a11f26a39f1c7f7107" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "getrandom" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.138" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d7e329c562c5dfab7a46a2afabc8b987ab9a4834c9d1ca04dc54c1546cef8" + +[[package]] +name = "once_cell" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860" + +[[package]] +name = "smtp-server" +version = "0.1.0" +dependencies = [ + "ahash", +] + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..397de51 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "smtp-server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ahash = { version = "0.8.0" } diff --git a/resources/config/config.toml b/resources/config/config.toml new file mode 100644 index 0000000..4353670 --- /dev/null +++ b/resources/config/config.toml @@ -0,0 +1,213 @@ +[servers] + +[servers."relay"] +hostname = "mx.example.org" +greeting = "Stalwart SMTP v0.1 at your service" +type = "smtp" +bind = ["0.0.0.0", 25] + +[servers."relay".tls] +enable = true, +implicit = true, +sni = "abc", +cert = "key", +protocols = "SSLv2, SSLv3, TLSv1, TLSv1.1, TLSv1.2, TLSv1.3", +ciphers = "aNULL:-aNULL:HIGH:MEDIUM:+RC4:@STRENGTH", +clientcert = true + +[servers."relay".socket] +backlog = 111, +ttl = 123, +send-buffer-size = 123, +recv-buffer-size = 444, +linger = 1, +tos = 1, + +[servers."relay".transaction.connect] +script = connect.sieve + +[[servers."relay".transaction.connect.throttle]] +concurrency = 1000 + +[[servers."relay".transaction.connect.throttle]] +key = remoteip + localip +concurrency = 10, +rate = [3, 60] + +[servers."relay".transaction.ehlo] +require = true +extensions = "chunking, pipeline, smtputf8, starttls" +script = ehlo.sieve +authenticate = spf + +[[servers."relay".transaction.ehlo]] +commands = 1 +idle = 10 + +[servers."relay".transaction.auth] +require = true +require-tls = true +auth-host = "auth-server" +mechanisms = "plain, login" + +[[servers."relay".transaction.auth.limits]] +idle = 10, +errors = 3, +errors-wait = 10 + +[servers."relay".transaction.mail] +authenticate = spf +script = mail-from.sieve + +[[servers."relay".transaction.mail.limits]] +idle = 10, +messages = 10 + +[[servers."relay".transaction.mail.throttle]] +key = mail-from, +concurrency = 10, +rate = [3, 60] + +[servers."relay".transaction.rcpt] +script = rcpt-to.sieve +local-domains = list-domains +local-addresses = list-addresses +cache = { entries = 1000, ttl-positive = 10, ttl-negative = 5 } + +[[servers."relay".transaction.rcpt.limits]] +idle = 10, +max-recipients = 100, +errors = 5 + +[[servers."relay".transaction.rcpt.throttle]] +key = rcpt-to +concurrency = 10 +rate = [3, 60] + +[servers."relay".transaction.data] +authenticate = [dkim, arc, dmarc] +sign = dkim, arc +script = data.sieve + +[[servers."relay".transaction.data.limits]] +idle = 10 +size = 100000 +received-headers = 50 +mime-parts = 50 +nested-messages = 3 + +[servers."relay".transaction.quit] +script = quit.sieve + +[servers."relay".transaction.disconnect] +script = disconnect.sieve + +[external] + +[external.lmtp] +address = 192.168.0.1 +port = 25 +protocol = "lmtp" +auth.username = "hello" +auth.password = "world" +tls = "optional, require, dane, dane-fallback-require, dane-require + +[queues] + +[queues."default"] +retry = [0, 1, 15, 60, 90] +notify = [9, 10] +prefer = ipv6 +source-ips = [192.168.0.2, 162.168.0.1] +tls = optional, require, dane, dane-fallback-require, dane-require + +[[queues."default".limits]] +attempts = 100 +time = 3600 +queued-messages = 10000 +queue-size = 1000000 + +[[queues."default".throttle]] +rate = 1/60 +concurrency = 1000 +key = all + +[[queues."default".throttle]] +rate = 1/60 +concurrency = 100 +key = localip, remote-ip, remote-mx + +[queues.local] +smart-host = lmtp +match-rule = "is-local" + +[rules] + +[rules."is-local"] +rcpt-domain = ["*.example.org"] +rcpt-to = [""] +server-id = "relay" +mx = ["mx.gmail.com", "mx.coco.com"] +remote-ip = [192.168.0.32/1] +priority = 1 + + + +[resolver] +type = system, google, cloudflare +dnssec = true +preserve-intermediates = true + +[resolver.limits] +concurrency = 2 +timeout = 100 +attempts = 3 + +[resolver.cache] +a = 1000 +mx = 9393 +txt = 3233 + +[general.spool] +path = "/var/spool/queue" +hash = 123 + +[scripts] + +[scripts.ehlo] +data = this is my script + +[lists] + +[lists.localdomains] +data = ["example.org", "*.example.net"] + +[list.localaddresses] +server = lmtp + +[certificates] + +[certificates.tls] +type = "rsa" +certificate = "" +privatekey = "" + +[servers."relay".outgoing.dsn] +name = "Mail Delivery Subsystem" +address = "MAILER-DAEMON" +subject = "Delivery Status Notification" + +[servers."relay".outgoing.auth-failure] +name = "Autentication Report" +address = "noreply-auth-failure" +subject = "Authentication Failure Report" + +[servers."relay".outgoing.dmarc] +name = "DMARC report" +address = "noreply-dmarc" +subject = "DMARC aggregate report for $1" + +[servers."relay".dmarc] +send-reports = true +report-frequency = requested, 86400 +incoming-address = "dmarc@*" diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..39f1a71 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,11 @@ +pub mod parser; +pub mod utils; + +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + keys: BTreeMap, +} + +pub type Result = std::result::Result; diff --git a/src/config/parser.rs b/src/config/parser.rs new file mode 100644 index 0000000..8bedd4e --- /dev/null +++ b/src/config/parser.rs @@ -0,0 +1,582 @@ +use std::{ + collections::{btree_map::Entry, BTreeMap}, + iter::Peekable, + str::Chars, +}; + +use super::{Config, Result}; +use std::fmt::Write; + +const MAX_NEST_LEVEL: usize = 4; + +// Simple TOML parser for Stalwart Mail Server configuration files. + +impl Config { + pub fn parse(toml: &str) -> Result { + let mut parser = TomlParser::new(toml); + let mut table_name = String::new(); + let mut last_array_name = String::new(); + let mut last_array_pos = 0; + + while parser.seek_next_char() { + match parser.peek_char()? { + '[' => { + parser.next_char(true, false)?; + table_name.clear(); + let mut is_array = match parser.next_char(true, false)? { + '[' => true, + ch => { + table_name.push(ch); + false + } + }; + let mut in_quote = false; + let mut last_ch = char::from(0); + loop { + let ch = parser.next_char(!in_quote, false)?; + match ch { + '\"' if !in_quote || last_ch != '\\' => { + in_quote = !in_quote; + } + '\\' if in_quote => (), + ']' if !in_quote => { + if table_name.is_empty() { + return Err(format!( + "Eempty table name at line {}.", + parser.line + )); + } + if is_array { + if table_name == last_array_name { + last_array_pos += 1; + } else { + last_array_pos = 0; + last_array_name = table_name.to_string(); + } + is_array = false; + write!(table_name, ".{}", last_array_pos).ok(); + } else { + break; + } + } + _ => { + if !in_quote { + if ch.is_alphanumeric() || ['.', '-', '_'].contains(&ch) { + table_name.push(ch.to_ascii_lowercase()); + } else { + return Err(format!( + "Unexpected character {:?} at line {}.", + ch, parser.line + )); + } + } else { + table_name.push(ch); + } + } + } + last_ch = ch; + } + parser.skip_line(); + } + 'a'..='z' | 'A'..='Z' | '0'..='9' | '\"' => { + let key = parser.key(if !table_name.is_empty() { + format!("{}.", table_name) + } else { + String::with_capacity(10) + })?; + parser.value(key, &['\n'], 0)?; + } + '#' => { + parser.skip_line(); + } + ch => { + let ch = *ch; + return Err(format!( + "Unexpected character {:?} at line {}.", + ch, parser.line + )); + } + } + } + + Ok(Self { keys: parser.keys }) + } +} + +struct TomlParser<'x> { + keys: BTreeMap, + iter: Peekable>, + line: usize, +} + +impl<'x> TomlParser<'x> { + fn new(toml: &'x str) -> Self { + Self { + keys: BTreeMap::new(), + iter: toml.chars().peekable(), + line: 1, + } + } + + fn seek_next_char(&mut self) -> bool { + while let Some(ch) = self.iter.peek() { + match ch { + '\n' => { + self.iter.next(); + self.line += 1; + } + '\r' | ' ' | '\t' => { + self.iter.next(); + } + '#' => { + self.skip_line(); + } + _ => { + return true; + } + } + } + + false + } + + fn peek_char(&mut self) -> Result<&char> { + self.iter.peek().ok_or_else(|| "".to_string()) + } + + fn next_char(&mut self, skip_wsp: bool, allow_lf: bool) -> Result { + for ch in &mut self.iter { + match ch { + '\r' => (), + ' ' | '\t' if skip_wsp => (), + '\n' => { + return if allow_lf { + self.line += 1; + Ok(ch) + } else { + Err(format!("Unexpected end of line at line: {}", self.line)) + }; + } + _ => { + return Ok(ch); + } + } + } + Err(format!("Unexpected EOF at line: {}", self.line)) + } + + fn skip_line(&mut self) { + for ch in &mut self.iter { + if ch == '\n' { + self.line += 1; + break; + } + } + } + + #[allow(clippy::while_let_on_iterator)] + fn key(&mut self, mut key: String) -> Result { + while let Some(ch) = self.iter.next() { + match ch { + '=' => { + if !key.is_empty() { + return Ok(key); + } else { + return Err(format!("Empty key at line: {}", self.line)); + } + } + 'a'..='z' | '.' | 'A'..='Z' | '0'..='9' | '_' | '-' => { + key.push(ch); + } + '\"' => { + let mut last_ch = char::from(0); + while let Some(ch) = self.iter.next() { + match ch { + '\\' => (), + '\"' if last_ch != '\\' => { + break; + } + '\n' => { + return Err(format!( + "Unexpected end of line at line: {}", + self.line + )); + } + _ => { + key.push(ch); + } + } + last_ch = ch; + } + } + ' ' | '\t' | '\r' => (), + '\n' => { + return Err(format!("Unexpected end of line at line: {}", self.line)); + } + _ => { + return Err(format!( + "Unexpected character {:?} found in key at line {}.", + ch, self.line + )); + } + } + } + Err(format!("Unexpected EOF at line: {}", self.line)) + } + + fn value(&mut self, key: String, stop_chars: &[char], nest_level: usize) -> Result { + if nest_level == MAX_NEST_LEVEL { + return Err(format!("Too many nested structures at line {}.", self.line)); + } + match self.next_char(true, false)? { + '[' => { + let mut array_pos = 0; + self.seek_next_char(); + loop { + match self.value( + format!("{}.{}", key, array_pos), + &[',', ']'], + nest_level + 1, + )? { + ',' => { + self.seek_next_char(); + array_pos += 1; + } + ']' => break, + ch => { + return Err(format!( + "Unexpected character {:?} found in array for key {:?} at line {}.", + ch, key, self.line + )); + } + } + } + } + '{' => loop { + let sub_key = self.key(format!("{}.", key))?; + self.seek_next_char(); + + match self.value(sub_key, &[',', '}'], nest_level + 1)? { + ',' => { + self.seek_next_char(); + } + '}' => break, + ch => { + return Err(format!( + "Unexpected character {:?} found in inline table for key {:?} at line {}.", + ch, key, self.line + )); + } + } + }, + qch @ ('\'' | '\"') => { + let mut value = String::new(); + if matches!(self.iter.peek(), Some(ch) if ch == &qch) { + self.iter.next(); + if matches!(self.iter.peek(), Some(ch) if ch == &qch) { + self.iter.next(); + if matches!(self.iter.peek(), Some(ch) if ch == &'\n') { + self.iter.next(); + self.line += 1; + } + + let mut last_ch = char::from(0); + let mut prev_last_ch = char::from(0); + loop { + let ch = self.next_char(false, true)?; + if !(ch == qch && last_ch == qch && prev_last_ch == qch) { + value.push(ch); + prev_last_ch = last_ch; + last_ch = ch; + } else { + value.truncate(value.len() - 2); + break; + } + } + } + } else { + let mut last_ch = char::from(0); + + loop { + let ch = self.next_char(false, true)?; + match ch { + '\\' if last_ch != '\\' => (), + 't' if last_ch == '\\' => { + value.push('\t'); + } + 'r' if last_ch == '\\' => { + value.push('\r'); + } + 'n' if last_ch == '\\' => { + value.push('\n'); + } + ch => { + if ch != qch || last_ch == '\\' { + value.push(ch); + } else { + break; + } + } + } + last_ch = ch; + } + } + match self.keys.entry(key) { + Entry::Vacant(e) => { + value.shrink_to_fit(); + e.insert(value); + } + Entry::Occupied(e) => { + return Err(format!( + "Duplicate key {:?} at line {}.", + e.key(), + self.line + )); + } + } + } + ch if ch.is_alphanumeric() => { + let mut value = String::with_capacity(4); + value.push(ch); + while let Some(ch) = self.iter.peek() { + if ch.is_alphanumeric() || ['.', '+', '.', ':'].contains(ch) { + value.push(self.next_char(true, false)?); + } else { + break; + } + } + match self.keys.entry(key) { + Entry::Vacant(e) => { + value.shrink_to_fit(); + e.insert(value); + } + Entry::Occupied(e) => { + return Err(format!( + "Duplicate key {:?} at line {}.", + e.key(), + self.line + )); + } + } + } + ch => { + return if stop_chars.contains(&ch) { + Ok(ch) + } else { + Err(format!( + "Expected {:?} but found {:?} in value at line {}.", + stop_chars, ch, self.line + )) + } + } + } + + loop { + match self.next_char(true, true)? { + '#' => { + self.skip_line(); + if stop_chars.contains(&'\n') { + return Ok('\n'); + } + } + ch if stop_chars.contains(&ch) => { + return Ok(ch); + } + '\n' if !stop_chars.contains(&'\n') => (), + ch => { + return Err(format!( + "Expected {:?} but found {:?} in value at line {}.", + stop_chars, ch, self.line + )); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::config::Config; + + #[test] + fn toml_parse() { + let toml = r#"[database] +enabled = true # ignore +ports = [ 8000, 8001, 8002 ] # ignore +data = [ ["delta", "phi"], [3.14] ] +temp_targets = { cpu = 79.5, case = 72.0 } + +[servers] +"127.0.0.1" = "value" # ignore +"character encoding" = "value" + +[servers.alpha] +ip = "10.0.0.1" +role = "frontend" + +[servers.beta] +ip = "10.0.0.2" +role = "backend" + +[[products]] +name = "Hammer" +sku = 738594937 + +[[products]] # empty table within the array + +[[products]] # ignore +name = "Nail" +sku = 284758393 # ignore +color = "gray" + +[strings."my \"string\" test"] +str1 = "I'm a string." +str2 = "You can \"quote\" me." +str3 = "Name\tTabs\nNew Line." +lines = ''' +The first newline is +trimmed in raw strings. +All other whitespace +is preserved. +''' + +[arrays] +integers = [ 1, 2, 3 ] +colors = [ "red", "yellow", "green" ] +nested_arrays_of_ints = [ [ 1, 2 ], [3, 4, 5] ] +nested_mixed_array = [ [ 1, 2 ], ["a", "b", "c"] ] +string_array = [ "all", 'strings', """are the same""", '''type''' ] + +# Mixed-type arrays are allowed +numbers = [ 0.1, 0.2, 0.5, 1, 2, 5 ] +integers2 = [ + 1, 2, 3 # this is ok +] +integers3 = [ + 4, + 5, # this is ok +] +contributors = [ + "Foo Bar ", + { name = "Baz Qux", email = "bazqux@example.com", url = "https://example.com/bazqux" } +] + +"#; + let config = Config::parse(toml).unwrap(); + assert_eq!( + config.keys, + BTreeMap::from_iter([ + ("arrays.colors.0".to_string(), "red".to_string()), + ("arrays.colors.1".to_string(), "yellow".to_string()), + ("arrays.colors.2".to_string(), "green".to_string()), + ( + "arrays.contributors.0".to_string(), + "Foo Bar ".to_string() + ), + ( + "arrays.contributors.1.email".to_string(), + "bazqux@example.com".to_string() + ), + ( + "arrays.contributors.1.name".to_string(), + "Baz Qux".to_string() + ), + ( + "arrays.contributors.1.url".to_string(), + "https://example.com/bazqux".to_string() + ), + ("arrays.integers.0".to_string(), "1".to_string()), + ("arrays.integers.1".to_string(), "2".to_string()), + ("arrays.integers.2".to_string(), "3".to_string()), + ("arrays.integers2.0".to_string(), "1".to_string()), + ("arrays.integers2.1".to_string(), "2".to_string()), + ("arrays.integers2.2".to_string(), "3".to_string()), + ("arrays.integers3.0".to_string(), "4".to_string()), + ("arrays.integers3.1".to_string(), "5".to_string()), + ( + "arrays.nested_arrays_of_ints.0.0".to_string(), + "1".to_string() + ), + ( + "arrays.nested_arrays_of_ints.0.1".to_string(), + "2".to_string() + ), + ( + "arrays.nested_arrays_of_ints.1.0".to_string(), + "3".to_string() + ), + ( + "arrays.nested_arrays_of_ints.1.1".to_string(), + "4".to_string() + ), + ( + "arrays.nested_arrays_of_ints.1.2".to_string(), + "5".to_string() + ), + ("arrays.nested_mixed_array.0.0".to_string(), "1".to_string()), + ("arrays.nested_mixed_array.0.1".to_string(), "2".to_string()), + ("arrays.nested_mixed_array.1.0".to_string(), "a".to_string()), + ("arrays.nested_mixed_array.1.1".to_string(), "b".to_string()), + ("arrays.nested_mixed_array.1.2".to_string(), "c".to_string()), + ("arrays.numbers.0".to_string(), "0.1".to_string()), + ("arrays.numbers.1".to_string(), "0.2".to_string()), + ("arrays.numbers.2".to_string(), "0.5".to_string()), + ("arrays.numbers.3".to_string(), "1".to_string()), + ("arrays.numbers.4".to_string(), "2".to_string()), + ("arrays.numbers.5".to_string(), "5".to_string()), + ("arrays.string_array.0".to_string(), "all".to_string()), + ("arrays.string_array.1".to_string(), "strings".to_string()), + ( + "arrays.string_array.2".to_string(), + "are the same".to_string() + ), + ("arrays.string_array.3".to_string(), "type".to_string()), + ("database.data.0.0".to_string(), "delta".to_string()), + ("database.data.0.1".to_string(), "phi".to_string()), + ("database.data.1.0".to_string(), "3.14".to_string()), + ("database.enabled".to_string(), "true".to_string()), + ("database.ports.0".to_string(), "8000".to_string()), + ("database.ports.1".to_string(), "8001".to_string()), + ("database.ports.2".to_string(), "8002".to_string()), + ("database.temp_targets.case".to_string(), "72.0".to_string()), + ("database.temp_targets.cpu".to_string(), "79.5".to_string()), + ("products.0.name".to_string(), "Hammer".to_string()), + ("products.0.sku".to_string(), "738594937".to_string()), + ("products.2.color".to_string(), "gray".to_string()), + ("products.2.name".to_string(), "Nail".to_string()), + ("products.2.sku".to_string(), "284758393".to_string()), + ("servers.127.0.0.1".to_string(), "value".to_string()), + ("servers.alpha.ip".to_string(), "10.0.0.1".to_string()), + ("servers.alpha.role".to_string(), "frontend".to_string()), + ("servers.beta.ip".to_string(), "10.0.0.2".to_string()), + ("servers.beta.role".to_string(), "backend".to_string()), + ( + "servers.character encoding".to_string(), + "value".to_string() + ), + ( + "strings.my \"string\" test.lines".to_string(), + concat!( + "The first newline is\ntrimmed in raw strings.\n", + "All other whitespace\nis preserved.\n" + ) + .to_string() + ), + ( + "strings.my \"string\" test.str1".to_string(), + "I'm a string.".to_string() + ), + ( + "strings.my \"string\" test.str2".to_string(), + "You can \"quote\" me.".to_string() + ), + ( + "strings.my \"string\" test.str3".to_string(), + "Name\tTabs\nNew Line.".to_string() + ), + ]) + ); + } +} diff --git a/src/config/utils.rs b/src/config/utils.rs new file mode 100644 index 0000000..13f53dc --- /dev/null +++ b/src/config/utils.rs @@ -0,0 +1,112 @@ +use std::str::FromStr; + +use super::Config; + +impl Config { + pub fn property( + &self, + group: &str, + id: &str, + key: &str, + ) -> super::Result> { + self.value(&format!("{}.{}.{}", group, id, key)) + } + + pub fn value(&self, key: &str) -> super::Result> { + if let Some(value) = self.keys.get(key) { + match T::from_str(value) { + Ok(result) => Ok(Some(result)), + Err(_) => Err(format!("Invalid value {:?} for key {:?}.", value, key)), + } + } else { + Ok(None) + } + } + + pub fn sub_keys<'x, 'y: 'x>(&'y self, prefix: &'x str) -> impl Iterator + 'x { + let mut last_key = ""; + self.keys.keys().filter_map(move |key| { + let key = key.strip_prefix(prefix)?.strip_prefix('.')?; + let key = if let Some((key, _)) = key.split_once('.') { + key + } else { + key + }; + if last_key != key { + last_key = key; + Some(key) + } else { + None + } + }) + } + + pub fn take_value(&mut self, key: &str) -> Option { + self.keys.remove(key) + } +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use crate::config::Config; + + #[test] + fn toml_utils() { + let toml = r#" +[queues."z"] +retry = [0, 1, 15, 60, 90] +value = "hi" + +[queues."x"] +retry = [3, 60] +value = "hi 2" + +[queues.a] +retry = [1, 2, 3, 4] +value = "hi 3" + +[servers."my relay"] +hostname = "mx.example.org" + +[[servers."my relay".transaction.auth.limits]] +idle = 10 + +[[servers."my relay".transaction.auth.limits]] +idle = 20 + +[servers."submissions"] +hostname = "submit.example.org" +ip = a:b::1:1 +"#; + let config = Config::parse(toml).unwrap(); + + assert_eq!( + config.sub_keys("queues").collect::>(), + ["a", "x", "z"] + ); + assert_eq!( + config.sub_keys("servers").collect::>(), + ["my relay", "submissions"] + ); + assert_eq!( + config.sub_keys("queues.z.retry").collect::>(), + ["0", "1", "2", "3", "4"] + ); + assert_eq!( + config + .value::("servers.my relay.transaction.auth.limits.1.idle") + .unwrap() + .unwrap(), + 20 + ); + assert_eq!( + config + .property::("servers", "submissions", "ip") + .unwrap() + .unwrap(), + "a:b::1:1".parse::().unwrap() + ); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6f50712 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,5 @@ +pub mod config; + +fn main() { + println!("Hello, world!"); +}