Skip to main content

vcon_server/
config.rs

1//! The server's configuration: one TOML file. A configuration error stops
2//! the start and names its key, and an unknown key is an error.
3
4use std::collections::BTreeSet;
5use std::fmt;
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use serde::Deserialize;
11
12use crate::retention::Retention;
13
14/// The configuration `serve` runs with. Durations are written in whole
15/// seconds and sizes in bytes; each must be more than zero, and each has a
16/// default.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Config {
19    pub listen: SocketAddr,
20    pub tls_cert: PathBuf,
21    pub tls_key: PathBuf,
22    pub data_dir: PathBuf,
23    pub master_key: PathBuf,
24    /// For tenants that set none; 7 years unless configured.
25    pub retention_default: Retention,
26    pub purge_interval: Duration,
27    pub rotation_warn: Duration,
28    /// The largest request body.
29    pub body_limit: usize,
30    /// The largest size gzip inflation may reach while a body is inspected.
31    pub inflated_limit: u64,
32    /// How long a request body may take to arrive.
33    pub read_timeout: Duration,
34    /// Extension names accepted in `critical` and `must_support`.
35    pub supported_critical: BTreeSet<String>,
36}
37
38/// Time between purge sweeps unless configured: a version is purged
39/// within an hour of falling due.
40pub const PURGE_INTERVAL: Duration = Duration::from_secs(3600);
41
42/// Age of the oldest work waiting for key rotation before `serve` warns,
43/// unless configured: 30 days.
44pub const ROTATION_WARN: Duration = Duration::from_secs(30 * 86_400);
45
46/// The largest request body unless configured: 16 MiB. The largest of
47/// 34,885 vCon files measured (public datasets and recorded test calls) was
48/// 5,141,813 bytes, a two-minute recording held inline; nearly all of their
49/// recordings are referenced by URL instead.
50pub const BODY_LIMIT: usize = 16 << 20;
51
52/// The largest inflation unless configured: four times [`BODY_LIMIT`], so
53/// any vCon accepted plain is also accepted gzipped, with room left for
54/// gzipped payloads inside its signed layers.
55pub const INFLATED_LIMIT: u64 = 4 * BODY_LIMIT as u64;
56
57/// How long a request body may take to arrive unless configured: a body at
58/// [`BODY_LIMIT`] needs about 2.2 Mbit/s.
59pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
60
61/// A configuration that could not be read, naming the key at fault.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ConfigError(pub String);
64
65impl fmt::Display for ConfigError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(&self.0)
68    }
69}
70
71/// Reads the configuration file at `path`.
72pub fn load(path: &Path) -> Result<Config, ConfigError> {
73    let text = std::fs::read_to_string(path)
74        .map_err(|e| ConfigError(format!("{}: {e}", path.display())))?;
75    parse(&text)
76}
77
78/// Reads a configuration from its TOML text.
79pub fn parse(text: &str) -> Result<Config, ConfigError> {
80    let raw: Raw = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
81    let retention_default = match raw.retention_default {
82        None => Retention::Years(7),
83        Some(text) => Retention::parse(&text).ok_or_else(|| {
84            ConfigError(format!(
85                "retention_default: {text:?} is not a period such as 7y, 18m or 90d"
86            ))
87        })?,
88    };
89    Ok(Config {
90        listen: raw.listen,
91        tls_cert: raw.tls.cert,
92        tls_key: raw.tls.key,
93        data_dir: raw.data_dir,
94        master_key: raw.master_key,
95        retention_default,
96        purge_interval: seconds("purge_interval", raw.purge_interval, PURGE_INTERVAL)?,
97        rotation_warn: seconds("rotation_warn", raw.rotation_warn, ROTATION_WARN)?,
98        body_limit: match raw.limits.body {
99            None => BODY_LIMIT,
100            Some(body) => usize::try_from(positive("limits.body", body)?)
101                .map_err(|_| ConfigError("limits.body is too large".to_owned()))?,
102        },
103        inflated_limit: match raw.limits.inflated {
104            None => INFLATED_LIMIT,
105            Some(inflated) => positive("limits.inflated", inflated)?,
106        },
107        read_timeout: seconds(
108            "limits.timeouts.read",
109            raw.limits.timeouts.read,
110            READ_TIMEOUT,
111        )?,
112        supported_critical: raw.supported_critical.into_iter().collect(),
113    })
114}
115
116fn positive(key: &str, value: u64) -> Result<u64, ConfigError> {
117    if value == 0 {
118        return Err(ConfigError(format!("{key} must be more than 0")));
119    }
120    Ok(value)
121}
122
123/// `value` in whole seconds, or `default` when it is not written.
124fn seconds(key: &str, value: Option<u64>, default: Duration) -> Result<Duration, ConfigError> {
125    match value {
126        None => Ok(default),
127        Some(value) => positive(key, value).map(Duration::from_secs),
128    }
129}
130
131/// The file as written, before its values are checked.
132#[derive(Deserialize)]
133#[serde(deny_unknown_fields)]
134struct Raw {
135    listen: SocketAddr,
136    tls: RawTls,
137    data_dir: PathBuf,
138    master_key: PathBuf,
139    retention_default: Option<String>,
140    purge_interval: Option<u64>,
141    rotation_warn: Option<u64>,
142    #[serde(default)]
143    limits: RawLimits,
144    #[serde(default)]
145    supported_critical: Vec<String>,
146}
147
148#[derive(Deserialize)]
149#[serde(deny_unknown_fields)]
150struct RawTls {
151    cert: PathBuf,
152    key: PathBuf,
153}
154
155#[derive(Default, Deserialize)]
156#[serde(deny_unknown_fields)]
157struct RawLimits {
158    body: Option<u64>,
159    inflated: Option<u64>,
160    #[serde(default)]
161    timeouts: RawTimeouts,
162}
163
164#[derive(Default, Deserialize)]
165#[serde(deny_unknown_fields)]
166struct RawTimeouts {
167    read: Option<u64>,
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    const FULL: &str = r#"
175listen = "127.0.0.1:8443"
176data_dir = "/var/lib/vcon-server"
177master_key = "/etc/vcon-server/master.key"
178retention_default = "18m"
179purge_interval = 1800
180rotation_warn = 604800
181supported_critical = ["known", "other"]
182
183[tls]
184cert = "/etc/vcon-server/cert.pem"
185key = "/etc/vcon-server/key.pem"
186
187[limits]
188body = 10485760
189inflated = 104857600
190
191[limits.timeouts]
192read = 30
193"#;
194
195    #[test]
196    fn a_full_configuration_is_read() {
197        assert_eq!(
198            parse(FULL),
199            Ok(Config {
200                listen: "127.0.0.1:8443".parse().expect("address"),
201                tls_cert: "/etc/vcon-server/cert.pem".into(),
202                tls_key: "/etc/vcon-server/key.pem".into(),
203                data_dir: "/var/lib/vcon-server".into(),
204                master_key: "/etc/vcon-server/master.key".into(),
205                retention_default: Retention::Months(18),
206                purge_interval: Duration::from_secs(1800),
207                rotation_warn: Duration::from_secs(604_800),
208                body_limit: 10_485_760,
209                inflated_limit: 104_857_600,
210                read_timeout: Duration::from_secs(30),
211                supported_critical: ["known", "other"].map(String::from).into(),
212            })
213        );
214    }
215
216    #[test]
217    fn retention_and_supported_extensions_have_defaults() {
218        let text = FULL
219            .replace("retention_default = \"18m\"\n", "")
220            .replace("supported_critical = [\"known\", \"other\"]\n", "");
221        let config = parse(&text).expect("parses");
222        assert_eq!(config.retention_default, Retention::Years(7));
223        assert_eq!(config.supported_critical, BTreeSet::new());
224    }
225
226    /// The configuration with only the keys that have no default.
227    const LEAST: &str = r#"
228listen = "127.0.0.1:8443"
229data_dir = "/var/lib/vcon-server"
230master_key = "/etc/vcon-server/master.key"
231
232[tls]
233cert = "/etc/vcon-server/cert.pem"
234key = "/etc/vcon-server/key.pem"
235"#;
236
237    #[test]
238    fn periods_and_limits_have_defaults() {
239        let config = parse(LEAST).expect("parses");
240        assert_eq!(config.purge_interval, Duration::from_secs(3600));
241        assert_eq!(config.rotation_warn, Duration::from_secs(30 * 86_400));
242        assert_eq!(config.body_limit, 16 << 20);
243        assert_eq!(config.inflated_limit, 64 << 20);
244        assert_eq!(config.read_timeout, Duration::from_secs(60));
245    }
246
247    /// A key left out of a table that is present takes its default; the
248    /// others keep their written values.
249    #[test]
250    fn each_limit_defaults_alone() {
251        let base = parse(FULL).expect("parses");
252        for (line, default) in [
253            (
254                "body = 10485760\n",
255                Config {
256                    body_limit: 16 << 20,
257                    ..base.clone()
258                },
259            ),
260            (
261                "inflated = 104857600\n",
262                Config {
263                    inflated_limit: 64 << 20,
264                    ..base.clone()
265                },
266            ),
267            (
268                "read = 30\n",
269                Config {
270                    read_timeout: Duration::from_secs(60),
271                    ..base.clone()
272                },
273            ),
274            (
275                "purge_interval = 1800\n",
276                Config {
277                    purge_interval: Duration::from_secs(3600),
278                    ..base.clone()
279                },
280            ),
281            (
282                "rotation_warn = 604800\n",
283                Config {
284                    rotation_warn: Duration::from_secs(30 * 86_400),
285                    ..base.clone()
286                },
287            ),
288            (
289                "[limits.timeouts]\nread = 30\n",
290                Config {
291                    read_timeout: Duration::from_secs(60),
292                    ..base.clone()
293                },
294            ),
295        ] {
296            assert!(FULL.contains(line), "{line}");
297            assert_eq!(parse(&FULL.replace(line, "")), Ok(default), "{line}");
298        }
299    }
300
301    /// The defaults differ from the full file's values, so each test above
302    /// tells a default from a written value.
303    #[test]
304    fn the_full_file_sets_no_value_equal_to_its_default() {
305        let (full, least) = (parse(FULL).expect("full"), parse(LEAST).expect("least"));
306        assert_ne!(full.purge_interval, least.purge_interval);
307        assert_ne!(full.rotation_warn, least.rotation_warn);
308        assert_ne!(full.body_limit, least.body_limit);
309        assert_ne!(full.inflated_limit, least.inflated_limit);
310        assert_ne!(full.read_timeout, least.read_timeout);
311    }
312
313    /// Each error names its key.
314    #[test]
315    fn every_missing_key_without_a_default_is_named() {
316        for (line, key) in [
317            ("listen = \"127.0.0.1:8443\"\n", "listen"),
318            ("data_dir = \"/var/lib/vcon-server\"\n", "data_dir"),
319            (
320                "master_key = \"/etc/vcon-server/master.key\"\n",
321                "master_key",
322            ),
323            ("cert = \"/etc/vcon-server/cert.pem\"\n", "cert"),
324            ("key = \"/etc/vcon-server/key.pem\"\n", "key"),
325        ] {
326            assert!(FULL.contains(line), "{line}");
327            let error = parse(&FULL.replace(line, "")).expect_err(key);
328            assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
329        }
330    }
331
332    #[test]
333    fn an_unknown_key_is_named() {
334        for (text, key) in [
335            (format!("colour = \"blue\"\n{FULL}"), "colour"),
336            (FULL.replace("read = 30", "read = 30\nwrite = 30"), "write"),
337            (FULL.replace("[tls]", "[tls]\nca = \"x\""), "ca"),
338        ] {
339            let error = parse(&text).expect_err(key);
340            assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
341        }
342    }
343
344    #[test]
345    fn a_bad_value_is_named() {
346        for (from, to, key) in [
347            ("\"127.0.0.1:8443\"", "\"localhost\"", "listen"),
348            ("\"18m\"", "\"7w\"", "retention_default"),
349            (
350                "purge_interval = 1800",
351                "purge_interval = 0",
352                "purge_interval",
353            ),
354            (
355                "rotation_warn = 604800",
356                "rotation_warn = 0",
357                "rotation_warn",
358            ),
359            ("body = 10485760", "body = 0", "limits.body"),
360            ("inflated = 104857600", "inflated = 0", "limits.inflated"),
361            ("read = 30", "read = 0", "limits.timeouts.read"),
362            ("read = 30", "read = -1", "read"),
363        ] {
364            let error = parse(&FULL.replace(from, to)).expect_err(key);
365            assert!(error.0.contains(key), "{key}: {error}");
366        }
367    }
368
369    /// The website's configuration page lists every key the full file
370    /// sets, each with the default the code gives it, and every example
371    /// file on the site reads.
372    mod site_page {
373        use super::*;
374
375        fn page(name: &str) -> String {
376            let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
377                .join("../../website/content/docs")
378                .join(name);
379            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
380        }
381
382        /// Every key in a TOML table, dotted.
383        fn keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
384            for (key, value) in table {
385                let name = format!("{prefix}{key}");
386                match value {
387                    toml::Value::Table(inner) => keys(inner, &format!("{name}."), out),
388                    _ => out.push(name),
389                }
390            }
391        }
392
393        /// The table row for `key`, by its first cell.
394        fn row<'a>(text: &'a str, key: &str) -> &'a str {
395            let start = format!("| `{key}` |");
396            let mut rows = text.lines().filter(|l| l.starts_with(&start));
397            let row = rows.next().unwrap_or_else(|| panic!("no row for {key}"));
398            assert!(rows.next().is_none(), "two rows for {key}");
399            row
400        }
401
402        #[test]
403        fn every_key_is_listed_with_its_default() {
404            let text = page("configuration.md");
405            let defaults = parse(LEAST).expect("least");
406            let mut all = Vec::new();
407            keys(&FULL.parse::<toml::Table>().expect("toml"), "", &mut all);
408            assert_eq!(all.len(), 12, "{all:?}");
409            for key in all {
410                let default = match key.as_str() {
411                    "listen" | "data_dir" | "master_key" | "tls.cert" | "tls.key" => {
412                        "required".to_owned()
413                    }
414                    "retention_default" => format!("`{}`", defaults.retention_default),
415                    "purge_interval" => format!("`{}`", defaults.purge_interval.as_secs()),
416                    "rotation_warn" => format!("`{}`", defaults.rotation_warn.as_secs()),
417                    "limits.body" => format!("`{}`", defaults.body_limit),
418                    "limits.inflated" => format!("`{}`", defaults.inflated_limit),
419                    "limits.timeouts.read" => format!("`{}`", defaults.read_timeout.as_secs()),
420                    "supported_critical" => {
421                        assert!(defaults.supported_critical.is_empty());
422                        "none".to_owned()
423                    }
424                    other => panic!("a key this test does not know: {other}"),
425                };
426                let row = row(&text, &key);
427                let last = row
428                    .trim_end_matches('|')
429                    .rsplit('|')
430                    .next()
431                    .expect("a last cell")
432                    .trim();
433                assert!(
434                    last.starts_with(&default),
435                    "{key}: `{last}` is not {default}"
436                );
437            }
438        }
439
440        /// The limits no key sets are stated as the code fixes them.
441        #[test]
442        fn every_fixed_limit_is_listed_with_its_value() {
443            let text = page("configuration.md");
444            for (limit, value) in [
445                ("Signed layers in one body", vcon::read::MAX_SIGNED_LAYERS),
446                (
447                    "Arrays and objects open at once",
448                    vcon::read::MAX_JSON_DEPTH,
449                ),
450            ] {
451                let start = format!("| {limit} |");
452                let rows: Vec<&str> = text.lines().filter(|l| l.starts_with(&start)).collect();
453                assert_eq!(rows.len(), 1, "one row for {limit}");
454                let cell = rows[0].split('|').nth(2).expect("a value cell").trim();
455                assert_eq!(cell, format!("`{value}`"), "{limit}");
456            }
457        }
458
459        #[test]
460        fn every_example_file_on_the_site_reads() {
461            let mut examples = 0;
462            for name in ["configuration.md", "getting-started.md"] {
463                let text = page(name);
464                for block in text.split("```toml\n").skip(1) {
465                    let toml = block.split("```").next().expect("a block");
466                    parse(toml).unwrap_or_else(|e| panic!("{name}: {e}\n{toml}"));
467                    examples += 1;
468                }
469            }
470            assert_eq!(examples, 2);
471        }
472    }
473
474    #[test]
475    fn a_missing_file_is_named() {
476        let error = load(Path::new("/nonexistent/vcon-server.toml")).expect_err("missing");
477        assert!(error.0.contains("/nonexistent/vcon-server.toml"), "{error}");
478    }
479}