use {
std::{
fmt::Write as _,
net::IpAddr,
path::{Path, PathBuf},
},
thiserror::Error,
};
const MAX_SERVERS: usize = 5;
const MAX_POOLS: usize = 1;
#[derive(Debug, Clone, PartialEq, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
logging: LoggingConfig,
sources: SourcesConfig,
}
impl Config {
#[must_use]
pub fn into_parts(self) -> (LoggingConfig, SourcesConfig) {
(self.logging, self.sources)
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct LoggingConfig {
log_directory: Option<PathBuf>,
synchronization: StreamConfig,
ffevents: StreamConfig,
}
impl LoggingConfig {
pub fn log_directory(&self) -> Option<&Path> {
self.log_directory.as_deref()
}
pub fn synchronization(&self) -> &StreamConfig {
&self.synchronization
}
pub fn ffevents(&self) -> &StreamConfig {
&self.ffevents
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct StreamConfig {
enabled: bool,
}
impl StreamConfig {
pub fn is_enabled(&self) -> bool {
self.enabled
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct SourcesConfig {
ntp: Vec<NtpSource>,
}
impl SourcesConfig {
pub fn ntp(&self) -> &[NtpSource] {
&self.ntp
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase", deny_unknown_fields)]
pub enum NtpSource {
Server(Host),
Pool(DomainName),
}
impl NtpSource {
fn value(&self) -> String {
match self {
Self::Server(host) => host.to_string(),
Self::Pool(name) => name.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(try_from = "String")]
pub struct DomainName(String);
impl TryFrom<String> for DomainName {
type Error = DomainNameError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
impl DomainName {
pub fn parse(value: String) -> Result<Self, DomainNameError> {
let name = value.trim();
if name.parse::<IpAddr>().is_ok() {
return Err(DomainNameError::IsAddress(value));
}
let final_label = name.trim_end_matches('.').rsplit('.').next().unwrap_or("");
if name.is_empty()
|| name.chars().any(|c| c.is_whitespace() || c.is_control())
|| name.contains(':')
|| final_label.is_empty()
|| final_label.bytes().all(|b| b.is_ascii_digit())
{
return Err(DomainNameError::NotAName(value));
}
Ok(Self(name.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum DomainNameError {
#[error("{0:?} is an IP address, not a name")]
IsAddress(String),
#[error("{0:?} is not a valid name")]
NotAName(String),
}
impl std::fmt::Display for DomainName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(try_from = "String")]
pub enum Host {
Ip(IpAddr),
Domain(DomainName),
}
impl Host {
pub fn parse(value: String) -> Result<Self, HostError> {
if let Ok(ip) = value.trim().parse::<IpAddr>() {
return Ok(Self::Ip(ip));
}
DomainName::parse(value)
.map(Self::Domain)
.map_err(|e| match e {
DomainNameError::IsAddress(value) | DomainNameError::NotAName(value) => {
HostError(value)
}
})
}
}
impl TryFrom<String> for Host {
type Error = HostError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(value)
}
}
#[derive(Debug, Error, PartialEq, Eq)]
#[error("{0:?} is not an IP address or a valid name")]
pub struct HostError(String);
impl std::fmt::Display for Host {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ip(ip) => write!(f, "{ip}"),
Self::Domain(name) => write!(f, "{name}"),
}
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Configuration file not found: {0}")]
NotFound(PathBuf),
#[error("Could not read configuration file {path}: {source}")]
Unreadable {
path: PathBuf,
source: std::io::Error,
},
#[error("Invalid configuration syntax: {0}")]
Parse(#[from] toml::de::Error),
#[error("Invalid configuration:\n{0}")]
Invalid(String),
}
impl Config {
pub fn load(path: Option<PathBuf>) -> Result<Config, ConfigError> {
let Some(path) = path else {
return Ok(Config::default());
};
let contents = std::fs::read_to_string(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
ConfigError::NotFound(path)
} else {
ConfigError::Unreadable { path, source }
}
})?;
let config: Config = toml::from_str(&contents)?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), ConfigError> {
let mut violations: Vec<String> = Vec::new();
let servers = self
.sources
.ntp
.iter()
.filter(|s| matches!(s, NtpSource::Server(_)))
.count();
let pools = self
.sources
.ntp
.iter()
.filter(|s| matches!(s, NtpSource::Pool(_)))
.count();
if servers > MAX_SERVERS {
violations.push(format!(
"at most {MAX_SERVERS} `server` entries are supported, found {servers}"
));
}
if pools > MAX_POOLS {
violations.push(format!(
"at most {MAX_POOLS} `pool` entry is supported, found {pools}"
));
}
let mut seen: Vec<String> = Vec::new();
for source in &self.sources.ntp {
let value = source.value();
if seen.contains(&value) {
violations.push(format!("duplicate source value: {value:?}"));
} else {
seen.push(value);
}
}
if let Some(dir) = &self.logging.log_directory
&& !dir.is_absolute()
{
violations.push(format!(
"`log_directory` must be an absolute path: {}",
dir.display()
));
}
let any_stream_enabled =
self.logging.synchronization.enabled || self.logging.ffevents.enabled;
if any_stream_enabled && self.logging.log_directory.is_none() {
violations
.push("`log_directory` is required when any log stream is enabled".to_string());
}
if violations.is_empty() {
return Ok(());
}
let mut message = String::new();
for violation in &violations {
let _ = writeln!(message, " - {violation}");
}
Err(ConfigError::Invalid(message.trim_end().to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
fn parse(s: &str) -> Result<Config, ConfigError> {
let config: Config = toml::from_str(s)?;
config.validate()?;
Ok(config)
}
fn server(value: &str) -> NtpSource {
NtpSource::Server(Host::parse(value.to_string()).unwrap())
}
fn pool(value: &str) -> NtpSource {
NtpSource::Pool(DomainName::parse(value.to_string()).unwrap())
}
#[test]
fn configurations_accepted() {
for (name, contents) in [
("empty", ""),
(
"logging with every stream",
indoc! {r#"
[logging]
log_directory = "/var/log/clockbound"
[logging.synchronization]
enabled = true
[logging.ffevents]
enabled = true
"#},
),
(
"addresses, names and a pool",
indoc! {r#"
[[sources.ntp]]
server = "192.0.2.1"
[[sources.ntp]]
server = "2001:db8::123"
[[sources.ntp]]
server = "ntp.corp.example"
[[sources.ntp]]
pool = "pool.ntp.example"
"#},
),
(
"logging and sources together",
indoc! {r#"
[logging]
log_directory = "/var/log/clockbound"
[logging.synchronization]
enabled = true
[[sources.ntp]]
server = "192.0.2.1"
[[sources.ntp]]
pool = "pool.ntp.example"
"#},
),
] {
parse(contents).unwrap_or_else(|e| panic!("expected {name:?} to be accepted: {e}"));
}
}
#[test]
fn configurations_rejected() {
for (name, contents, expected) in [
(
"malformed syntax",
indoc! {r#"
[logging]
log_directory = = "/var/log/clockbound"
"#},
"Invalid configuration syntax",
),
(
"unknown setting",
indoc! {r#"
[logging]
log_directory = "/var/log/clockbound"
rotate = true
"#},
"unknown field `rotate`",
),
(
"both server and pool in one block",
indoc! {r#"
[[sources.ntp]]
server = "ntp.corp.example"
pool = "pool.ntp.example"
"#},
"Invalid configuration syntax",
),
(
"enabled stream without a log directory",
indoc! {r#"
[logging.ffevents]
enabled = true
"#},
"`log_directory` is required when any log stream is enabled",
),
(
"relative log directory",
indoc! {r#"
[logging]
log_directory = "clockbound/logs"
[logging.ffevents]
enabled = true
"#},
"`log_directory` must be an absolute path",
),
(
"wrong type for a setting",
indoc! {r#"
[logging.ffevents]
enabled = "yes"
"#},
"Invalid configuration syntax",
),
(
"a source block naming neither a server nor a pool",
indoc! {r#"
[[sources.ntp]]
"#},
"Invalid configuration syntax",
),
(
"pool given an address",
indoc! {r#"
[[sources.ntp]]
pool = "192.0.2.1"
"#},
"\"192.0.2.1\" is an IP address, not a name",
),
(
"pool given something that cannot be a name",
indoc! {r#"
[[sources.ntp]]
pool = "192.0.2.999"
"#},
"\"192.0.2.999\" is not a valid name",
),
(
"source that is neither an address nor a name",
indoc! {r#"
[[sources.ntp]]
server = "192.0.2.999"
"#},
"\"192.0.2.999\" is not an IP address or a valid name",
),
(
"more pools than are supported",
indoc! {r#"
[[sources.ntp]]
pool = "a.example"
[[sources.ntp]]
pool = "b.example"
"#},
"at most 1 `pool` entry is supported, found 2",
),
(
"more servers than are supported",
indoc! {r#"
[[sources.ntp]]
server = "192.0.2.1"
[[sources.ntp]]
server = "192.0.2.2"
[[sources.ntp]]
server = "192.0.2.3"
[[sources.ntp]]
server = "192.0.2.4"
[[sources.ntp]]
server = "192.0.2.5"
[[sources.ntp]]
server = "192.0.2.6"
"#},
"at most 5 `server` entries are supported, found 6",
),
(
"the same source twice",
indoc! {r#"
[[sources.ntp]]
server = "ntp.corp.example"
[[sources.ntp]]
server = "ntp.corp.example"
"#},
"duplicate source value",
),
] {
let error = parse(contents)
.expect_err(&format!("expected {name:?} to be rejected"))
.to_string();
assert!(
error.contains(expected),
"{name:?}: expected {expected:?} in error, got: {error}"
);
}
}
#[test]
fn empty_is_default() {
assert_eq!(parse("").unwrap(), Config::default());
}
#[test]
fn no_path_is_default() {
assert_eq!(Config::load(None).unwrap(), Config::default());
}
#[test]
fn example_config_parses_and_validates() {
parse(include_str!("../../assets/example-config.toml")).unwrap();
}
#[test]
fn unknown_field_rejected_top_level() {
assert!(matches!(parse("bogus = true"), Err(ConfigError::Parse(_))));
}
#[test]
fn unknown_field_rejected_nested() {
assert!(matches!(
parse(indoc! {r"
[logging]
bogus = true
"}),
Err(ConfigError::Parse(_))
));
}
#[test]
fn unknown_field_rejected_in_stream() {
assert!(matches!(
parse(indoc! {r"
[logging.ffevents]
enabled = true
bogus = 1
"}),
Err(ConfigError::Parse(_))
));
}
#[test]
fn unknown_field_rejected_in_sources() {
assert!(matches!(
parse(indoc! {r"
[sources]
bogus = 1
"}),
Err(ConfigError::Parse(_))
));
}
#[test]
fn unknown_key_in_ntp_source_rejected() {
assert!(matches!(
parse(indoc! {r#"
[[sources.ntp]]
bogus = "x"
"#}),
Err(ConfigError::Parse(_))
));
}
#[test]
fn parse_error_preserves_line_and_column() {
let ConfigError::Parse(_) = parse("bogus = = 1\n").unwrap_err() else {
panic!("expected a Parse error");
};
let rendered = parse("bogus = = 1\n").unwrap_err().to_string();
assert!(
rendered.contains("line") && rendered.contains("column"),
"expected a line/column snippet, got: {rendered}"
);
}
#[test]
fn type_error_rejected() {
assert!(matches!(
parse(indoc! {r#"
[logging.ffevents]
enabled = "yes"
"#}),
Err(ConfigError::Parse(_))
));
}
#[test]
fn both_server_and_pool_rejected() {
assert!(matches!(
parse(indoc! {r#"
[[sources.ntp]]
server = "192.0.2.1"
pool = "pool.ntp.example"
"#}),
Err(ConfigError::Parse(_))
));
}
#[test]
fn neither_server_nor_pool_rejected() {
assert!(matches!(
parse("[[sources.ntp]]\n"),
Err(ConfigError::Parse(_))
));
}
#[test]
fn server_alone_accepted() {
let (_, sources) = parse(indoc! {r#"
[[sources.ntp]]
server = "192.0.2.1"
"#})
.unwrap()
.into_parts();
assert_eq!(sources.ntp(), [server("192.0.2.1")]);
}
#[test]
fn pool_alone_accepted() {
let (_, sources) = parse(indoc! {r#"
[[sources.ntp]]
pool = "pool.ntp.example"
"#})
.unwrap()
.into_parts();
assert_eq!(sources.ntp(), [pool("pool.ntp.example")]);
}
#[test]
fn host_accepts_addresses_and_names() {
for value in [
"192.0.2.1",
"2001:db8::123",
"::1",
"::",
"::ffff:192.0.2.1",
"ntp.corp.example",
"NTP.CORP.EXAMPLE",
"time-1.example",
"my_internal_ntp",
"café.example",
"1.example",
"192.0.2.example",
"ntp.corp.example.",
"localhost",
] {
Host::parse(value.to_string())
.unwrap_or_else(|e| panic!("expected {value:?} to be accepted: {e}"));
}
}
#[test]
fn host_rejects_what_is_neither_address_nor_name() {
for value in [
"192.0.2.999",
"10.0.0",
"010.0.0.1",
"192.0.2999",
"1.2.3.4.5",
"192.0.2.1.",
"12345",
"2001:db8::gggg",
"ntp.corp.example:123",
"ntp .corp.example",
"ntp\u{a0}corp.example",
"",
" ",
".",
"..",
"bad\tname",
"bad\nname",
] {
let error = Host::parse(value.to_string())
.expect_err(&format!("expected {value:?} to be rejected"));
assert_eq!(
error.to_string(),
format!("{value:?} is not an IP address or a valid name")
);
}
}
#[test]
fn surrounding_whitespace_is_trimmed() {
for value in [
" ntp.corp.example",
"ntp.corp.example ",
"\tntp.corp.example\n",
] {
let name = DomainName::parse(value.to_string())
.unwrap_or_else(|e| panic!("expected {value:?} to be accepted: {e}"));
assert_eq!(name.as_str(), "ntp.corp.example");
}
assert_eq!(
Host::parse(" 192.0.2.1 ".to_string()).unwrap(),
Host::Ip("192.0.2.1".parse().unwrap())
);
}
#[test]
fn domain_name_rejects_an_address() {
for value in ["192.0.2.1", "2001:db8::123"] {
let error = DomainName::parse(value.to_string())
.expect_err(&format!("expected {value:?} to be rejected"));
assert_eq!(
error.to_string(),
format!("{value:?} is an IP address, not a name")
);
}
}
#[test]
fn domain_name_rejects_what_cannot_be_a_name() {
for value in ["", " ", "bad\tname", "192.0.2.999", "12345", "1:2"] {
let error = DomainName::parse(value.to_string())
.expect_err(&format!("expected {value:?} to be rejected"));
assert_eq!(error.to_string(), format!("{value:?} is not a valid name"));
}
}
#[test]
fn exactly_max_servers_accepted() {
let ntp = (0..MAX_SERVERS)
.map(|i| server(&format!("10.0.0.{i}")))
.collect();
let config = Config {
sources: SourcesConfig { ntp },
..Config::default()
};
config.validate().unwrap();
}
#[test]
fn too_many_servers_rejected() {
let ntp = (0..=MAX_SERVERS)
.map(|i| server(&format!("10.0.0.{i}")))
.collect();
let config = Config {
sources: SourcesConfig { ntp },
..Config::default()
};
assert_eq!(
config.validate().unwrap_err().to_string(),
"Invalid configuration:\n - at most 5 `server` entries are supported, found 6",
);
}
#[test]
fn too_many_pools_rejected() {
let config = Config {
sources: SourcesConfig {
ntp: vec![pool("a.example"), pool("b.example")],
},
..Config::default()
};
assert_eq!(
config.validate().unwrap_err().to_string(),
"Invalid configuration:\n - at most 1 `pool` entry is supported, found 2",
);
}
#[test]
fn duplicate_source_rejected() {
let config = Config {
sources: SourcesConfig {
ntp: vec![server("192.0.2.1"), server("192.0.2.1")],
},
..Config::default()
};
assert_eq!(
config.validate().unwrap_err().to_string(),
"Invalid configuration:\n - duplicate source value: \"192.0.2.1\"",
);
}
#[test]
fn same_name_as_a_server_and_a_pool_is_a_duplicate() {
let config = Config {
sources: SourcesConfig {
ntp: vec![server("ntp.corp.example"), pool("ntp.corp.example")],
},
..Config::default()
};
assert_eq!(
config.validate().unwrap_err().to_string(),
"Invalid configuration:\n - duplicate source value: \"ntp.corp.example\"",
);
}
#[test]
fn enabled_stream_requires_log_directory() {
let config = Config {
logging: LoggingConfig {
log_directory: None,
ffevents: StreamConfig { enabled: true },
..LoggingConfig::default()
},
..Config::default()
};
assert_eq!(
config.validate().unwrap_err().to_string(),
"Invalid configuration:\n - `log_directory` is required when any log stream is enabled",
);
}
#[test]
fn relative_log_directory_is_rejected() {
let config = Config {
logging: LoggingConfig {
log_directory: Some(PathBuf::from("relative/path")),
..LoggingConfig::default()
},
..Config::default()
};
assert_eq!(
config.validate().unwrap_err().to_string(),
"Invalid configuration:\n - `log_directory` must be an absolute path: relative/path",
);
}
#[test]
fn disabled_streams_need_no_log_directory() {
Config::default().validate().unwrap();
}
#[test]
fn reports_every_violation() {
let config = Config {
logging: LoggingConfig {
ffevents: StreamConfig { enabled: true },
..LoggingConfig::default()
},
sources: SourcesConfig {
ntp: vec![
server("ntp.corp.example"),
server("ntp.corp.example"),
pool("a.example"),
pool("b.example"),
],
},
};
let error = config.validate().unwrap_err().to_string();
for expected in [
"at most 1 `pool` entry is supported, found 2",
"duplicate source value: \"ntp.corp.example\"",
"`log_directory` is required when any log stream is enabled",
] {
assert!(
error.contains(expected),
"expected {expected:?} in: {error}"
);
}
}
#[test]
fn missing_explicit_file_is_not_found() {
assert_eq!(
Config::load(Some(PathBuf::from("/nonexistent/clockbound.toml")))
.unwrap_err()
.to_string(),
"Configuration file not found: /nonexistent/clockbound.toml",
);
}
#[test]
fn load_reads_parses_and_validates_a_file() {
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(
file,
indoc! {r#"
[logging]
log_directory = "/var/log/clockbound"
[logging.ffevents]
enabled = true
"#}
)
.unwrap();
let (logging, _) = Config::load(Some(file.path().to_path_buf()))
.unwrap()
.into_parts();
assert!(logging.ffevents().is_enabled());
assert_eq!(
logging.log_directory(),
Some(Path::new("/var/log/clockbound"))
);
}
#[test]
fn load_rejects_a_semantic_violation() {
use std::io::Write as _;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(
file,
indoc! {r#"
[[sources.ntp]]
pool = "a.example"
[[sources.ntp]]
pool = "b.example"
"#}
)
.unwrap();
assert_eq!(
Config::load(Some(file.path().to_path_buf()))
.unwrap_err()
.to_string(),
"Invalid configuration:\n - at most 1 `pool` entry is supported, found 2",
);
}
#[test]
fn unreadable_file_reported() {
let dir = tempfile::tempdir().unwrap();
let err = Config::load(Some(dir.path().to_path_buf())).unwrap_err();
assert!(matches!(err, ConfigError::Unreadable { .. }));
}
}