/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::{
collections::{btree_map::Entry, BTreeMap},
iter::Peekable,
str::Chars,
};
use super::{Config, Result};
use std::fmt::Write;
const MAX_NEST_LEVEL: usize = 10;
// 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!(
"Empty 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 property {:?} 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 property {:?} 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() || ['.', '+', '-'].contains(&ch) => {
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, fs, path::PathBuf};
use crate::config::Config;
#[test]
fn toml_parse() {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("resources");
file.push("tests");
file.push("config");
file.push("toml-parser.toml");
let config = Config::parse(&fs::read_to_string(file).unwrap()).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()
),
])
);
}
}