Files
Stalwart/crates/utils/src/config/parser.rs
2025-06-15 15:09:32 +02:00

595 lines
22 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::{
collections::{BTreeMap, btree_map::Entry},
iter::Peekable,
str::Chars,
};
use super::{Config, Result};
use std::fmt::Write;
const MAX_NEST_LEVEL: usize = 10;
// Simple TOML parser for Stalwart Server configuration files.
impl Config {
pub fn new(toml: impl AsRef<str>) -> Result<Self> {
let mut config = Config::default();
config.parse(toml.as_ref())?;
Ok(config)
}
pub fn parse(&mut self, toml: &str) -> Result<()> {
let mut parser = TomlParser::new(&mut self.keys, 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:04}").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)
},
false,
)?;
parser.value(key, &['\n'], 0)?;
}
'#' => {
parser.skip_line();
}
ch => {
let ch = *ch;
return Err(format!(
"Unexpected character {:?} at line {}.",
ch, parser.line
));
}
}
}
Ok(())
}
}
struct TomlParser<'x, 'y> {
keys: &'y mut BTreeMap<String, String>,
iter: Peekable<Chars<'x>>,
line: usize,
}
impl<'x, 'y> TomlParser<'x, 'y> {
fn new(keys: &'y mut BTreeMap<String, String>, toml: &'x str) -> Self {
Self {
keys,
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, in_curly: bool) -> Result<(String, char)> {
let start_key_len = key.len();
while let Some(ch) = self.iter.next() {
match ch {
'=' => {
if start_key_len != key.len() {
return Ok((key, ch));
} else {
return Err(format!("Empty key at line: {}", self.line));
}
}
',' | '}' if in_curly => {
if start_key_len != key.len() {
return Ok((key, ch));
} 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 while parsing quoted key at line: {}",
self.line
));
}
_ => {
key.push(ch);
}
}
last_ch = ch;
}
}
' ' | '\t' | '\r' => (),
'\n' => {
if start_key_len == key.len() {
self.line += 1;
} else {
return Err(format!(
"Unexpected end of line while parsing key {:?} at line: {}",
key, 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:04}"),
&[',', ']'],
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
));
}
}
}
}
'{' => {
let base_key = format!("{key}.");
let base_key_len = base_key.len();
loop {
let (sub_key, stop_char) = self.key(base_key.clone(), true)?;
match stop_char {
'=' => {
// Key value
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
));
}
}
}
',' => {
// Set
if sub_key.len() > base_key_len {
self.insert_key(sub_key, String::new())?;
}
}
'}' => {
// Set
if sub_key.len() > base_key_len {
self.insert_key(sub_key, String::new())?;
}
break;
}
_ => unreachable!(),
}
}
}
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;
}
}
self.insert_key(key, value)?;
}
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;
}
}
self.insert_key(key, value)?;
}
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
));
}
}
}
}
fn insert_key(&mut self, key: String, mut value: String) -> Result<()> {
match self.keys.entry(key) {
Entry::Vacant(e) => {
value.shrink_to_fit();
e.insert(value);
Ok(())
}
Entry::Occupied(e) => Err(format!(
"Duplicate key {:?} at line {}.",
e.key(),
self.line
)),
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, fs, path::PathBuf};
use crate::config::Config;
#[test]
fn toml_parse() {
let file = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf()
.join("tests")
.join("resources")
.join("smtp")
.join("config")
.join("toml-parser.toml");
let mut config = Config::default();
config.parse(&fs::read_to_string(file).unwrap()).unwrap();
let expected = BTreeMap::from_iter(
[
("arrays.colors.0000", "red"),
("arrays.colors.0001", "yellow"),
("arrays.colors.0002", "green"),
("arrays.contributors.0000", "Foo Bar <foo@example.com>"),
("arrays.contributors.0001.email", "bazqux@example.com"),
("arrays.contributors.0001.name", "Baz Qux"),
("arrays.contributors.0001.url", "https://example.com/bazqux"),
("arrays.integers.0000", "1"),
("arrays.integers.0001", "2"),
("arrays.integers.0002", "3"),
("arrays.integers2.0000", "1"),
("arrays.integers2.0001", "2"),
("arrays.integers2.0002", "3"),
("arrays.integers3.0000", "4"),
("arrays.integers3.0001", "5"),
("arrays.nested_arrays_of_ints.0000.0000", "1"),
("arrays.nested_arrays_of_ints.0000.0001", "2"),
("arrays.nested_arrays_of_ints.0001.0000", "3"),
("arrays.nested_arrays_of_ints.0001.0001", "4"),
("arrays.nested_arrays_of_ints.0001.0002", "5"),
("arrays.nested_mixed_array.0000.0000", "1"),
("arrays.nested_mixed_array.0000.0001", "2"),
("arrays.nested_mixed_array.0001.0000", "a"),
("arrays.nested_mixed_array.0001.0001", "b"),
("arrays.nested_mixed_array.0001.0002", "c"),
("arrays.numbers.0000", "0.1"),
("arrays.numbers.0001", "0.2"),
("arrays.numbers.0002", "0.5"),
("arrays.numbers.0003", "1"),
("arrays.numbers.0004", "2"),
("arrays.numbers.0005", "5"),
("arrays.string_array.0000", "all"),
("arrays.string_array.0001", "strings"),
("arrays.string_array.0002", "are the same"),
("arrays.string_array.0003", "type"),
("database.data.0000.0000", "delta"),
("database.data.0000.0001", "phi"),
("database.data.0001.0000", "3.14"),
("database.enabled", "true"),
("database.ports.0000", "8000"),
("database.ports.0001", "8001"),
("database.ports.0002", "8002"),
("database.temp_targets.case", "72.0"),
("database.temp_targets.cpu", "79.5"),
("products.0000.name", "Hammer"),
("products.0000.sku", "738594937"),
("products.0002.color", "gray"),
("products.0002.name", "Nail"),
("products.0002.sku", "284758393"),
("servers.127.0.0.1", "value"),
("servers.alpha.ip", "10.0.0.1"),
("servers.alpha.role", "frontend"),
("servers.beta.ip", "10.0.0.2"),
("servers.beta.role", "backend"),
("servers.character encoding", "value"),
(
"strings.my \"string\" test.lines",
concat!(
"The first newline is\ntrimmed in raw strings.\n",
"All other whitespace\nis preserved.\n"
),
),
("strings.my \"string\" test.str1", "I'm a string."),
("strings.my \"string\" test.str2", "You can \"quote\" me."),
("strings.my \"string\" test.str3", "Name\tTabs\nNew Line."),
("env.var1", "utils"),
("env.var2", "utils"),
("sets.integer.1", ""),
("sets.integers.1", ""),
("sets.integers.2", ""),
("sets.integers.3", ""),
("sets.string.red", ""),
("sets.strings.red", ""),
("sets.strings.yellow", ""),
("sets.strings.green", ""),
]
.map(|(k, v)| (k.to_string(), v.to_string())),
);
if config.keys != expected {
for (key, value) in &config.keys {
if let Some(expected_value) = expected.get(key) {
if value != expected_value {
panic!(
"Expected value {:?} for key {:?} but found {:?}.",
expected_value, key, value
);
}
} else {
panic!(
"Unexpected key {:?} found in config with value {:?}.",
key, value
);
}
}
for (key, value) in &expected {
if let Some(config_value) = config.keys.get(key) {
if value != config_value {
panic!(
"Expected value {:?} for key {:?} but found {:?}.",
value, key, config_value
);
}
} else {
panic!(
"Expected key {:?} not found in config with value {:?}.",
key, value
);
}
}
}
assert_eq!(
config.set_values("sets.strings").collect::<Vec<_>>(),
vec!["green", "red", "yellow"]
);
assert_eq!(
config.sub_keys("sets.strings", "").collect::<Vec<_>>(),
vec!["green", "red", "yellow"]
);
assert_eq!(
config.sub_keys("sets", ".red").collect::<Vec<_>>(),
vec!["string", "strings"]
);
}
}