Skip to main content

rusty_time_core/
config.rs

1//! chrony.conf-compatible configuration (the documented subset).
2//!
3//! Unknown directives are collected, never fatal: `config.migrate` honesty means
4//! reporting what we dropped, not silently eating it.
5
6use core::fmt;
7
8#[derive(Clone, Debug, PartialEq)]
9pub struct ServerDirective {
10    pub host: String,
11    pub is_pool: bool,
12    pub iburst: bool,
13    pub min_poll: Option<i8>,
14    pub max_poll: Option<i8>,
15}
16
17#[derive(Clone, Debug, Default, PartialEq)]
18pub struct Config {
19    pub servers: Vec<ServerDirective>,
20    /// (threshold seconds, update limit) — chrony `makestep`.
21    pub makestep: Option<(f64, u32)>,
22    /// chrony `maxslewrate`, ppm.
23    pub max_slew_ppm: Option<f64>,
24    pub allow: Vec<String>,
25    pub deny: Vec<String>,
26    /// Directives we recognized as chrony's but do not implement yet, with line
27    /// numbers (1-based).
28    pub ignored: Vec<(usize, String)>,
29}
30
31#[derive(Clone, Debug, PartialEq)]
32pub struct ConfigError {
33    pub line: usize,
34    pub message: String,
35}
36
37impl fmt::Display for ConfigError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "config line {}: {}", self.line, self.message)
40    }
41}
42
43impl std::error::Error for ConfigError {}
44
45pub fn parse(text: &str) -> Result<Config, ConfigError> {
46    let mut cfg = Config::default();
47    for (idx, raw) in text.lines().enumerate() {
48        let line_no = idx + 1;
49        let line = raw.trim();
50        // chrony comment characters: '!', ';', '#', '%'.
51        if line.is_empty() || matches!(line.as_bytes()[0], b'!' | b';' | b'#' | b'%') {
52            continue;
53        }
54        let mut words = line.split_whitespace();
55        let Some(directive) = words.next() else {
56            continue;
57        };
58        let args: Vec<&str> = words.collect();
59        match directive.to_ascii_lowercase().as_str() {
60            d @ ("server" | "pool") => {
61                let host = args
62                    .first()
63                    .ok_or_else(|| err(line_no, format!("{d} needs a host")))?;
64                let mut s = ServerDirective {
65                    host: (*host).to_string(),
66                    is_pool: d == "pool",
67                    iburst: false,
68                    min_poll: None,
69                    max_poll: None,
70                };
71                let mut rest = args[1..].iter();
72                while let Some(opt) = rest.next() {
73                    match opt.to_ascii_lowercase().as_str() {
74                        "iburst" => s.iburst = true,
75                        "minpoll" => {
76                            s.min_poll = Some(parse_num(line_no, "minpoll", rest.next().copied())?)
77                        }
78                        "maxpoll" => {
79                            s.max_poll = Some(parse_num(line_no, "maxpoll", rest.next().copied())?)
80                        }
81                        other => {
82                            cfg.ignored.push((line_no, format!("{d} option '{other}'")));
83                            // Skip a value-taking option's argument when known.
84                            if matches!(other, "key" | "maxdelay" | "maxdelayratio" | "presend") {
85                                let _ = rest.next();
86                            }
87                        }
88                    }
89                }
90                cfg.servers.push(s);
91            }
92            "makestep" => {
93                let threshold: f64 =
94                    parse_num(line_no, "makestep threshold", args.first().copied())?;
95                let limit: i64 = parse_num(line_no, "makestep limit", args.get(1).copied())?;
96                // chrony: negative limit = always allowed.
97                let limit = if limit < 0 { u32::MAX } else { limit as u32 };
98                cfg.makestep = Some((threshold, limit));
99            }
100            "maxslewrate" => {
101                cfg.max_slew_ppm = Some(parse_num(line_no, "maxslewrate", args.first().copied())?);
102            }
103            "allow" => cfg
104                .allow
105                .push(args.first().copied().unwrap_or("all").to_string()),
106            "deny" => cfg
107                .deny
108                .push(args.first().copied().unwrap_or("all").to_string()),
109            other => {
110                cfg.ignored.push((line_no, other.to_string()));
111            }
112        }
113    }
114    Ok(cfg)
115}
116
117fn err(line: usize, message: String) -> ConfigError {
118    ConfigError { line, message }
119}
120
121fn parse_num<T: core::str::FromStr>(
122    line: usize,
123    what: &str,
124    value: Option<&str>,
125) -> Result<T, ConfigError> {
126    let v = value.ok_or_else(|| err(line, format!("{what} needs a value")))?;
127    v.parse()
128        .map_err(|_| err(line, format!("{what}: cannot parse '{v}'")))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn parses_typical_chrony_conf() {
137        let text = "\
138# NTP servers
139pool 2.pool.ntp.org iburst
140server ntp.example.com iburst minpoll 4 maxpoll 8
141makestep 1.0 3
142maxslewrate 83333.0
143driftfile /var/lib/chrony/drift
144rtcsync
145allow 192.168.0.0/16
146";
147        let cfg = parse(text).expect("parse");
148        assert_eq!(cfg.servers.len(), 2);
149        assert!(cfg.servers[0].is_pool && cfg.servers[0].iburst);
150        assert_eq!(cfg.servers[1].min_poll, Some(4));
151        assert_eq!(cfg.makestep, Some((1.0, 3)));
152        assert_eq!(cfg.max_slew_ppm, Some(83333.0));
153        assert_eq!(cfg.allow, vec!["192.168.0.0/16".to_string()]);
154        // driftfile + rtcsync recognized as dropped, with line numbers.
155        assert_eq!(cfg.ignored.len(), 2);
156        assert_eq!(cfg.ignored[0], (6, "driftfile".to_string()));
157    }
158
159    #[test]
160    fn negative_makestep_limit_means_always() {
161        let cfg = parse("makestep 0.1 -1").expect("parse");
162        assert_eq!(cfg.makestep, Some((0.1, u32::MAX)));
163    }
164
165    #[test]
166    fn bad_value_is_an_error_with_line() {
167        let e = parse("server\n").expect_err("should fail");
168        assert_eq!(e.line, 1);
169        let e = parse("# ok\nmakestep abc 3").expect_err("should fail");
170        assert_eq!(e.line, 2);
171    }
172}