TOML parser

This commit is contained in:
Mauro D 2022-12-07 17:13:14 +00:00
parent 915c8902b7
commit 5eafc06786
8 changed files with 995 additions and 4 deletions

4
.gitignore vendored
View file

@ -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

63
Cargo.lock generated Normal file
View file

@ -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"

9
Cargo.toml Normal file
View file

@ -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" }

View file

@ -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@*"

11
src/config/mod.rs Normal file
View file

@ -0,0 +1,11 @@
pub mod parser;
pub mod utils;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
keys: BTreeMap<String, String>,
}
pub type Result<T> = std::result::Result<T, String>;

582
src/config/parser.rs Normal file
View file

@ -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<Self> {
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<String, String>,
iter: Peekable<Chars<'x>>,
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<char> {
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<String> {
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<char> {
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 <foo@example.com>",
{ 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 <foo@example.com>".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()
),
])
);
}
}

112
src/config/utils.rs Normal file
View file

@ -0,0 +1,112 @@
use std::str::FromStr;
use super::Config;
impl Config {
pub fn property<T: FromStr>(
&self,
group: &str,
id: &str,
key: &str,
) -> super::Result<Option<T>> {
self.value(&format!("{}.{}.{}", group, id, key))
}
pub fn value<T: FromStr>(&self, key: &str) -> super::Result<Option<T>> {
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<Item = &str> + '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<String> {
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::<Vec<_>>(),
["a", "x", "z"]
);
assert_eq!(
config.sub_keys("servers").collect::<Vec<_>>(),
["my relay", "submissions"]
);
assert_eq!(
config.sub_keys("queues.z.retry").collect::<Vec<_>>(),
["0", "1", "2", "3", "4"]
);
assert_eq!(
config
.value::<u32>("servers.my relay.transaction.auth.limits.1.idle")
.unwrap()
.unwrap(),
20
);
assert_eq!(
config
.property::<IpAddr>("servers", "submissions", "ip")
.unwrap()
.unwrap(),
"a:b::1:1".parse::<IpAddr>().unwrap()
);
}
}

5
src/main.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod config;
fn main() {
println!("Hello, world!");
}