mirror of
https://github.com/stalwartlabs/smtp-server.git
synced 2024-11-24 15:20:16 +00:00
TOML parser
This commit is contained in:
parent
915c8902b7
commit
5eafc06786
8 changed files with 995 additions and 4 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -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
63
Cargo.lock
generated
Normal 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
9
Cargo.toml
Normal 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" }
|
213
resources/config/config.toml
Normal file
213
resources/config/config.toml
Normal 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
11
src/config/mod.rs
Normal 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
582
src/config/parser.rs
Normal 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
112
src/config/utils.rs
Normal 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
5
src/main.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod config;
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
Loading…
Reference in a new issue