use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;
use std::string::{String, ToString};
use std::vec::Vec;
#[derive(Debug, Clone, Default)]
pub struct DaemonConfig {
pub listen: String,
pub domain: i32,
pub log_level: String,
pub topics: Vec<TopicConfig>,
pub tls_enabled: bool,
pub tls_cert_file: String,
pub tls_key_file: String,
pub tls_client_ca_file: String,
pub auth_mode: String,
pub auth_bearer_token: Option<String>,
pub auth_bearer_subject: Option<String>,
pub topic_acl: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
pub metrics_enabled: bool,
pub metrics_addr: String,
}
#[derive(Debug, Clone, Default)]
pub struct TopicConfig {
pub name: String,
pub type_name: String,
pub direction: String,
pub ws_path: String,
pub reliability: String,
pub durability: String,
pub history_depth: i32,
}
#[derive(Debug, Clone)]
pub enum ConfigError {
Io(String),
Syntax(String),
MissingField(String),
BadValue {
field: String,
value: String,
},
}
impl core::fmt::Display for ConfigError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Io(m) => write!(f, "config io: {m}"),
Self::Syntax(m) => write!(f, "config syntax: {m}"),
Self::MissingField(m) => write!(f, "config missing field: {m}"),
Self::BadValue { field, value } => {
write!(f, "config bad value for {field}: {value}")
}
}
}
}
impl std::error::Error for ConfigError {}
impl DaemonConfig {
#[must_use]
pub fn default_for_dev() -> Self {
Self {
listen: "127.0.0.1:8080".to_string(),
domain: 0,
log_level: "info".to_string(),
topics: Vec::new(),
tls_enabled: false,
tls_cert_file: String::new(),
tls_key_file: String::new(),
tls_client_ca_file: String::new(),
auth_mode: "none".to_string(),
auth_bearer_token: None,
auth_bearer_subject: None,
topic_acl: std::collections::HashMap::new(),
metrics_enabled: false,
metrics_addr: String::new(),
}
}
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
let raw = fs::read_to_string(path).map_err(|e| ConfigError::Io(e.to_string()))?;
Self::load_from_str(&raw)
}
pub fn load_from_str(raw: &str) -> Result<Self, ConfigError> {
let expanded = expand_env_vars(raw);
let nodes = parse_yaml_subset(&expanded)?;
let mut out = Self::default_for_dev();
for (k, v) in nodes.iter() {
match k.as_str() {
"listen" => out.listen = v.as_scalar()?,
"domain" => {
let s = v.as_scalar()?;
out.domain = s.parse().map_err(|_| ConfigError::BadValue {
field: "domain".to_string(),
value: s,
})?;
}
"log_level" => out.log_level = v.as_scalar()?,
"tls" => {
if let YamlNode::Map(m) = v {
if let Some(YamlNode::Scalar(s)) = m.get("enabled") {
out.tls_enabled = parse_bool(s);
}
if let Some(YamlNode::Scalar(s)) = m.get("cert_file") {
out.tls_cert_file = s.clone();
}
if let Some(YamlNode::Scalar(s)) = m.get("key_file") {
out.tls_key_file = s.clone();
}
if let Some(YamlNode::Scalar(s)) = m.get("client_ca_file") {
out.tls_client_ca_file = s.clone();
}
}
}
"auth" => {
if let YamlNode::Map(m) = v {
if let Some(YamlNode::Scalar(s)) = m.get("mode") {
out.auth_mode = s.clone();
}
if let Some(YamlNode::Scalar(s)) = m.get("bearer_token") {
out.auth_bearer_token = Some(s.clone());
}
if let Some(YamlNode::Scalar(s)) = m.get("bearer_subject") {
out.auth_bearer_subject = Some(s.clone());
}
}
}
"acl" => {
if let YamlNode::Map(m) = v {
for (topic, entry) in m.iter() {
if let YamlNode::Map(em) = entry {
let read = em
.get("read")
.and_then(|n| match n {
YamlNode::Scalar(s) => Some(
s.split(',').map(|x| x.trim().to_string()).collect(),
),
_ => None,
})
.unwrap_or_default();
let write = em
.get("write")
.and_then(|n| match n {
YamlNode::Scalar(s) => Some(
s.split(',').map(|x| x.trim().to_string()).collect(),
),
_ => None,
})
.unwrap_or_default();
out.topic_acl.insert(topic.clone(), (read, write));
}
}
}
}
"metrics" => {
if let YamlNode::Map(m) = v {
if let Some(YamlNode::Scalar(s)) = m.get("enabled") {
out.metrics_enabled = parse_bool(s);
}
if let Some(YamlNode::Scalar(s)) = m.get("address") {
out.metrics_addr = s.clone();
}
}
}
"topics" => {
if let YamlNode::Seq(items) = v {
for item in items.iter() {
if let YamlNode::Map(m) = item {
let mut t = TopicConfig::default();
if let Some(YamlNode::Scalar(s)) = m.get("name") {
t.name = s.clone();
}
if let Some(YamlNode::Scalar(s)) = m.get("type") {
t.type_name = s.clone();
}
if let Some(YamlNode::Scalar(s)) = m.get("direction") {
t.direction = s.clone();
} else {
t.direction = "bidir".to_string();
}
if let Some(YamlNode::Scalar(s)) = m.get("ws_path") {
t.ws_path = s.clone();
}
if let Some(YamlNode::Map(qm)) = m.get("qos") {
if let Some(YamlNode::Scalar(s)) = qm.get("reliability") {
t.reliability = s.clone();
}
if let Some(YamlNode::Scalar(s)) = qm.get("durability") {
t.durability = s.clone();
}
if let Some(YamlNode::Map(hm)) = qm.get("history") {
if let Some(YamlNode::Scalar(s)) = hm.get("depth") {
t.history_depth = s.parse().unwrap_or(10);
}
}
}
if t.name.is_empty() {
return Err(ConfigError::MissingField(
"topics[].name".to_string(),
));
}
if t.type_name.is_empty() {
t.type_name = t.name.clone();
}
if t.ws_path.is_empty() {
t.ws_path = default_ws_path(&t.name);
}
out.topics.push(t);
}
}
}
}
_ => {} }
}
Ok(out)
}
}
#[must_use]
pub fn default_ws_path(topic: &str) -> String {
let mut buf = String::from("/topics/");
let lower = topic.to_ascii_lowercase();
let bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b':' && bytes[i + 1] == b':' {
buf.push('/');
i += 2;
continue;
}
let c = bytes[i] as char;
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/' {
buf.push(c);
} else {
buf.push('_');
}
i += 1;
}
buf
}
fn parse_bool(s: &str) -> bool {
matches!(s.trim().to_ascii_lowercase().as_str(), "true" | "yes" | "1")
}
#[must_use]
pub fn expand_env_vars(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let chars: Vec<char> = input.chars().collect();
let mut i = 0;
while i < chars.len() {
if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' {
if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') {
let inner: String = chars[i + 2..i + 2 + end].iter().collect();
let (name, default) = match inner.split_once(":-") {
Some((n, d)) => (n.to_string(), Some(d.to_string())),
None => (inner.clone(), None),
};
let value = env::var(&name).ok().or(default).unwrap_or_default();
out.push_str(&value);
i += 2 + end + 1;
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
#[derive(Debug, Clone)]
enum YamlNode {
Scalar(String),
Seq(Vec<YamlNode>),
Map(BTreeMap<String, YamlNode>),
}
impl YamlNode {
fn as_scalar(&self) -> Result<String, ConfigError> {
match self {
Self::Scalar(s) => Ok(s.clone()),
_ => Err(ConfigError::Syntax("expected scalar".to_string())),
}
}
}
fn parse_yaml_subset(raw: &str) -> Result<BTreeMap<String, YamlNode>, ConfigError> {
let mut lines: Vec<(usize, String)> = Vec::new();
for line in raw.split('\n') {
let stripped = strip_comment(line);
if stripped.trim().is_empty() {
continue;
}
let indent = stripped.chars().take_while(|c| *c == ' ').count();
let content = stripped[indent..].to_string();
lines.push((indent, content));
}
let (out, _) = parse_block_map(&lines, 0, 0)?;
Ok(out)
}
fn strip_comment(line: &str) -> String {
let mut out = String::new();
let mut in_quote: Option<char> = None;
for c in line.chars() {
match in_quote {
Some(q) => {
out.push(c);
if c == q {
in_quote = None;
}
}
None => {
if c == '#' {
break;
}
if c == '"' || c == '\'' {
in_quote = Some(c);
}
out.push(c);
}
}
}
out.trim_end().to_string()
}
fn parse_block_map(
lines: &[(usize, String)],
start: usize,
indent: usize,
) -> Result<(BTreeMap<String, YamlNode>, usize), ConfigError> {
let mut map = BTreeMap::new();
let mut i = start;
while i < lines.len() {
let (line_indent, content) = &lines[i];
if *line_indent < indent {
break;
}
if *line_indent > indent {
return Err(ConfigError::Syntax(alloc_format(format_args!(
"unexpected indent at line containing {content}"
))));
}
if content.starts_with("- ") || content.as_str() == "-" {
return Err(ConfigError::Syntax(
"unexpected sequence marker in map context".to_string(),
));
}
let (key, value) = match content.split_once(':') {
Some((k, v)) => (k.trim().to_string(), v.trim().to_string()),
None => {
return Err(ConfigError::Syntax(alloc_format(format_args!(
"no `:` in line: {content}"
))));
}
};
if !value.is_empty() {
map.insert(key, YamlNode::Scalar(unquote(&value)));
i += 1;
} else {
i += 1;
if i >= lines.len() || lines[i].0 <= indent {
map.insert(key, YamlNode::Scalar(String::new()));
continue;
}
let child_indent = lines[i].0;
let child_content = &lines[i].1;
if child_content.starts_with("- ") || child_content.as_str() == "-" {
let (seq, advanced) = parse_block_seq(lines, i, child_indent)?;
map.insert(key, YamlNode::Seq(seq));
i = advanced;
} else {
let (sub, advanced) = parse_block_map(lines, i, child_indent)?;
map.insert(key, YamlNode::Map(sub));
i = advanced;
}
}
}
Ok((map, i))
}
fn parse_block_seq(
lines: &[(usize, String)],
start: usize,
indent: usize,
) -> Result<(Vec<YamlNode>, usize), ConfigError> {
let mut seq = Vec::new();
let mut i = start;
while i < lines.len() {
let (line_indent, content) = &lines[i];
if *line_indent < indent {
break;
}
if *line_indent > indent {
return Err(ConfigError::Syntax("seq misindented".to_string()));
}
if !content.starts_with('-') {
break;
}
let after_dash = if content == "-" {
String::new()
} else if content.starts_with("- ") {
content[2..].to_string()
} else {
return Err(ConfigError::Syntax("malformed seq item".to_string()));
};
if after_dash.is_empty() {
i += 1;
if i >= lines.len() || lines[i].0 <= indent {
seq.push(YamlNode::Scalar(String::new()));
continue;
}
let child_indent = lines[i].0;
let (sub, advanced) = parse_block_map(lines, i, child_indent)?;
seq.push(YamlNode::Map(sub));
i = advanced;
} else if let Some((k, v)) = after_dash.split_once(':') {
let k = k.trim().to_string();
let v = v.trim();
let mut sub = BTreeMap::new();
if v.is_empty() {
i += 1;
if i >= lines.len() {
sub.insert(k, YamlNode::Scalar(String::new()));
} else if lines[i].0 > indent + 2 {
let ci = lines[i].0;
let child = &lines[i].1;
if child.starts_with("- ") || child == "-" {
let (s2, advanced) = parse_block_seq(lines, i, ci)?;
sub.insert(k, YamlNode::Seq(s2));
i = advanced;
} else {
let (m2, advanced) = parse_block_map(lines, i, ci)?;
sub.insert(k, YamlNode::Map(m2));
i = advanced;
}
} else {
sub.insert(k, YamlNode::Scalar(String::new()));
}
} else {
sub.insert(k, YamlNode::Scalar(unquote(v)));
i += 1;
}
let item_member_indent = indent + 2;
while i < lines.len() {
let (li, lc) = &lines[i];
if *li < item_member_indent {
break;
}
if *li == indent && (lc.starts_with("- ") || lc == "-") {
break;
}
if *li != item_member_indent {
break;
}
if lc.starts_with("- ") {
break;
}
let (kk, vv) = lc
.split_once(':')
.ok_or_else(|| ConfigError::Syntax("seq map missing colon".to_string()))?;
let kk = kk.trim().to_string();
let vv = vv.trim();
if vv.is_empty() {
i += 1;
if i < lines.len() && lines[i].0 > item_member_indent {
let ci = lines[i].0;
let child = &lines[i].1;
if child.starts_with("- ") || child == "-" {
let (s2, advanced) = parse_block_seq(lines, i, ci)?;
sub.insert(kk, YamlNode::Seq(s2));
i = advanced;
} else {
let (m2, advanced) = parse_block_map(lines, i, ci)?;
sub.insert(kk, YamlNode::Map(m2));
i = advanced;
}
} else {
sub.insert(kk, YamlNode::Scalar(String::new()));
}
} else {
sub.insert(kk, YamlNode::Scalar(unquote(vv)));
i += 1;
}
}
seq.push(YamlNode::Map(sub));
} else {
seq.push(YamlNode::Scalar(unquote(&after_dash)));
i += 1;
}
}
Ok((seq, i))
}
fn unquote(v: &str) -> String {
let v = v.trim();
if (v.starts_with('"') && v.ends_with('"') && v.len() >= 2)
|| (v.starts_with('\'') && v.ends_with('\'') && v.len() >= 2)
{
v[1..v.len() - 1].to_string()
} else {
v.to_string()
}
}
fn alloc_format(args: core::fmt::Arguments<'_>) -> String {
use core::fmt::Write as _;
let mut s = String::new();
let _ = s.write_fmt(args);
s
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn slug_strips_double_colon() {
assert_eq!(default_ws_path("Chat::Message"), "/topics/chat/message");
}
#[test]
fn slug_replaces_unsafe_chars() {
assert_eq!(default_ws_path("My Topic!"), "/topics/my_topic_");
}
#[test]
fn env_substitution_with_default() {
let s = expand_env_vars("token: ${ZERODDS_PROBABLY_UNSET_VAR_e2afb0b9_test:-fallback}");
assert!(s.contains("fallback"), "got: {s}");
}
#[test]
fn env_substitution_passthrough_when_no_placeholder() {
let s = expand_env_vars("plain: value");
assert_eq!(s, "plain: value");
}
#[test]
fn parse_minimal_config() {
let yaml = "\
listen: \"0.0.0.0:8080\"
domain: 0
log_level: info
topics:
- name: \"Chat::Message\"
type: \"Chat::Message\"
direction: bidir
";
let cfg = DaemonConfig::load_from_str(yaml).unwrap();
assert_eq!(cfg.listen, "0.0.0.0:8080");
assert_eq!(cfg.domain, 0);
assert_eq!(cfg.topics.len(), 1);
assert_eq!(cfg.topics[0].name, "Chat::Message");
assert_eq!(cfg.topics[0].direction, "bidir");
assert_eq!(cfg.topics[0].ws_path, "/topics/chat/message");
}
#[test]
fn parse_qos_block() {
let yaml = "\
listen: 0.0.0.0:8080
domain: 0
topics:
- name: T
qos:
reliability: reliable
durability: volatile
history:
depth: 25
";
let cfg = DaemonConfig::load_from_str(yaml).unwrap();
assert_eq!(cfg.topics[0].reliability, "reliable");
assert_eq!(cfg.topics[0].durability, "volatile");
assert_eq!(cfg.topics[0].history_depth, 25);
}
#[test]
fn parse_tls_and_auth_blocks() {
let yaml = "\
listen: 0.0.0.0:8080
domain: 0
tls:
enabled: true
auth:
mode: bearer
bearer_token: secret
metrics:
enabled: true
topics:
- name: T
";
let cfg = DaemonConfig::load_from_str(yaml).unwrap();
assert!(cfg.tls_enabled);
assert_eq!(cfg.auth_mode, "bearer");
assert_eq!(cfg.auth_bearer_token.as_deref(), Some("secret"));
assert!(cfg.metrics_enabled);
}
#[test]
fn parse_rejects_bad_domain() {
let yaml = "\
listen: x
domain: notanint
";
let err = DaemonConfig::load_from_str(yaml).unwrap_err();
assert!(matches!(err, ConfigError::BadValue { .. }));
}
}