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    /// The address and port `serve` listens on (`listen`).
20    pub listen: SocketAddr,
21    /// The file holding the server's PEM certificate chain (`tls.cert`).
22    pub tls_cert: PathBuf,
23    /// The file holding the server's PEM private key (`tls.key`).
24    pub tls_key: PathBuf,
25    /// The data directory (`data_dir`).
26    pub data_dir: PathBuf,
27    /// The file holding the 32-byte master key (`master_key`).
28    pub master_key: PathBuf,
29    /// For tenants that set none; 7 years unless configured.
30    pub retention_default: Retention,
31    /// Time between purge sweeps; [`PURGE_INTERVAL`] unless configured.
32    pub purge_interval: Duration,
33    /// Age of the oldest work waiting for key rotation before `serve`
34    /// warns; [`ROTATION_WARN`] unless configured.
35    pub rotation_warn: Duration,
36    /// The largest request body.
37    pub body_limit: usize,
38    /// The largest size gzip inflation may reach while a body is inspected.
39    pub inflated_limit: u64,
40    /// How long a request body may take to arrive.
41    pub read_timeout: Duration,
42    /// Extension names accepted in `critical` and `must_support`.
43    pub supported_critical: BTreeSet<String>,
44    /// The MCP server's limits.
45    pub mcp: crate::mcp::Limits,
46}
47
48/// Time between purge sweeps unless configured: a version is purged
49/// within an hour of falling due.
50pub const PURGE_INTERVAL: Duration = Duration::from_secs(3600);
51
52/// Age of the oldest work waiting for key rotation before `serve` warns,
53/// unless configured: 30 days.
54pub const ROTATION_WARN: Duration = Duration::from_secs(30 * 86_400);
55
56/// The largest request body unless configured: 16 MiB. The largest of
57/// 34,885 vCon files measured (public datasets and recorded test calls) was
58/// 5,141,813 bytes, a two-minute recording held inline; nearly all of their
59/// recordings are referenced by URL instead.
60pub const BODY_LIMIT: usize = 16 << 20;
61
62/// The largest inflation unless configured: four times [`BODY_LIMIT`], so
63/// any vCon accepted plain is also accepted gzipped, with room left for
64/// gzipped payloads inside its signed layers.
65pub const INFLATED_LIMIT: u64 = 4 * BODY_LIMIT as u64;
66
67/// How long a request body may take to arrive unless configured: a body at
68/// [`BODY_LIMIT`] needs about 2.2 Mbit/s.
69pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
70
71/// A configuration that could not be read, naming the key at fault.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ConfigError(pub String);
74
75impl fmt::Display for ConfigError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81impl std::error::Error for ConfigError {}
82
83/// Reads the configuration file at `path`.
84///
85/// # Errors
86///
87/// A [`ConfigError`] naming the file if it cannot be read, or any error
88/// [`parse`] gives for its text.
89pub fn load(path: &Path) -> Result<Config, ConfigError> {
90    let text = std::fs::read_to_string(path)
91        .map_err(|e| ConfigError(format!("{}: {e}", path.display())))?;
92    parse(&text)
93}
94
95/// Reads a configuration from its TOML text.
96///
97/// # Errors
98///
99/// A [`ConfigError`] naming the key at fault: text that is not TOML, an
100/// unknown key, a required key missing, or a value of the wrong type or
101/// not more than zero.
102pub fn parse(text: &str) -> Result<Config, ConfigError> {
103    let raw: Raw = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
104    let retention_default = match raw.retention_default {
105        None => Retention::Years(7),
106        Some(text) => Retention::parse(&text).ok_or_else(|| {
107            ConfigError(format!(
108                "retention_default: {text:?} is not a period such as 7y, 18m or 90d"
109            ))
110        })?,
111    };
112    Ok(Config {
113        listen: raw.listen,
114        tls_cert: raw.tls.cert,
115        tls_key: raw.tls.key,
116        data_dir: raw.data_dir,
117        master_key: raw.master_key,
118        retention_default,
119        purge_interval: seconds("purge_interval", raw.purge_interval, PURGE_INTERVAL)?,
120        rotation_warn: seconds("rotation_warn", raw.rotation_warn, ROTATION_WARN)?,
121        body_limit: match raw.limits.body {
122            None => BODY_LIMIT,
123            Some(body) => usize::try_from(positive("limits.body", body)?)
124                .map_err(|_| ConfigError("limits.body is too large".to_owned()))?,
125        },
126        inflated_limit: match raw.limits.inflated {
127            None => INFLATED_LIMIT,
128            Some(inflated) => positive("limits.inflated", inflated)?,
129        },
130        read_timeout: seconds(
131            "limits.timeouts.read",
132            raw.limits.timeouts.read,
133            READ_TIMEOUT,
134        )?,
135        supported_critical: raw.supported_critical.into_iter().collect(),
136        mcp: mcp_limits(&raw.mcp)?,
137    })
138}
139
140/// The `[mcp]` table's limits, each key that is not written at its default.
141fn mcp_limits(raw: &RawMcp) -> Result<crate::mcp::Limits, ConfigError> {
142    let default = crate::mcp::Limits::DEFAULT;
143    let narrow = |key: &str, value: u64| {
144        u32::try_from(positive(key, value)?).map_err(|_| ConfigError(format!("{key} is too large")))
145    };
146    Ok(crate::mcp::Limits {
147        calls_per_second: match raw.calls_per_second {
148            None => default.calls_per_second,
149            Some(value) => narrow("mcp.calls_per_second", value)?,
150        },
151        concurrent: match raw.concurrent {
152            None => default.concurrent,
153            Some(value) => narrow("mcp.concurrent", value)?,
154        },
155        response_bytes: match raw.response_bytes {
156            None => default.response_bytes,
157            Some(value) => usize::try_from(positive("mcp.response_bytes", value)?)
158                .map_err(|_| ConfigError("mcp.response_bytes is too large".to_owned()))?,
159        },
160    })
161}
162
163fn positive(key: &str, value: u64) -> Result<u64, ConfigError> {
164    if value == 0 {
165        return Err(ConfigError(format!("{key} must be more than 0")));
166    }
167    Ok(value)
168}
169
170/// `value` in whole seconds, or `default` when it is not written.
171fn seconds(key: &str, value: Option<u64>, default: Duration) -> Result<Duration, ConfigError> {
172    match value {
173        None => Ok(default),
174        Some(value) => positive(key, value).map(Duration::from_secs),
175    }
176}
177
178/// The file as written, before its values are checked.
179#[derive(Deserialize)]
180#[serde(deny_unknown_fields)]
181struct Raw {
182    listen: SocketAddr,
183    tls: RawTls,
184    data_dir: PathBuf,
185    master_key: PathBuf,
186    retention_default: Option<String>,
187    purge_interval: Option<u64>,
188    rotation_warn: Option<u64>,
189    #[serde(default)]
190    limits: RawLimits,
191    #[serde(default)]
192    supported_critical: Vec<String>,
193    #[serde(default)]
194    mcp: RawMcp,
195}
196
197#[derive(Default, Deserialize)]
198#[serde(deny_unknown_fields)]
199struct RawMcp {
200    calls_per_second: Option<u64>,
201    concurrent: Option<u64>,
202    response_bytes: Option<u64>,
203}
204
205#[derive(Deserialize)]
206#[serde(deny_unknown_fields)]
207struct RawTls {
208    cert: PathBuf,
209    key: PathBuf,
210}
211
212#[derive(Default, Deserialize)]
213#[serde(deny_unknown_fields)]
214struct RawLimits {
215    body: Option<u64>,
216    inflated: Option<u64>,
217    #[serde(default)]
218    timeouts: RawTimeouts,
219}
220
221#[derive(Default, Deserialize)]
222#[serde(deny_unknown_fields)]
223struct RawTimeouts {
224    read: Option<u64>,
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    const FULL: &str = r#"
232listen = "127.0.0.1:8443"
233data_dir = "/var/lib/vcon-server"
234master_key = "/etc/vcon-server/master.key"
235retention_default = "18m"
236purge_interval = 1800
237rotation_warn = 604800
238supported_critical = ["known", "other"]
239
240[tls]
241cert = "/etc/vcon-server/cert.pem"
242key = "/etc/vcon-server/key.pem"
243
244[limits]
245body = 10485760
246inflated = 104857600
247
248[limits.timeouts]
249read = 30
250
251[mcp]
252calls_per_second = 20
253concurrent = 8
254response_bytes = 262144
255"#;
256
257    #[test]
258    fn a_full_configuration_is_read() {
259        assert_eq!(
260            parse(FULL),
261            Ok(Config {
262                listen: "127.0.0.1:8443".parse().expect("address"),
263                tls_cert: "/etc/vcon-server/cert.pem".into(),
264                tls_key: "/etc/vcon-server/key.pem".into(),
265                data_dir: "/var/lib/vcon-server".into(),
266                master_key: "/etc/vcon-server/master.key".into(),
267                retention_default: Retention::Months(18),
268                purge_interval: Duration::from_secs(1800),
269                rotation_warn: Duration::from_secs(604_800),
270                body_limit: 10_485_760,
271                inflated_limit: 104_857_600,
272                read_timeout: Duration::from_secs(30),
273                supported_critical: ["known", "other"].map(String::from).into(),
274                mcp: crate::mcp::Limits {
275                    calls_per_second: 20,
276                    concurrent: 8,
277                    response_bytes: 262_144,
278                },
279            }),
280            "every key the file writes is read into its field"
281        );
282    }
283
284    #[test]
285    fn retention_and_supported_extensions_have_defaults() {
286        let text = FULL
287            .replace("retention_default = \"18m\"\n", "")
288            .replace("supported_critical = [\"known\", \"other\"]\n", "");
289        let config = parse(&text).expect("parses");
290        assert_eq!(
291            config.retention_default,
292            Retention::Years(7),
293            "with no retention_default, vCons are kept seven years"
294        );
295        assert_eq!(
296            config.supported_critical,
297            BTreeSet::new(),
298            "with no supported_critical, no extension is supported"
299        );
300    }
301
302    /// The configuration with only the keys that have no default.
303    const LEAST: &str = r#"
304listen = "127.0.0.1:8443"
305data_dir = "/var/lib/vcon-server"
306master_key = "/etc/vcon-server/master.key"
307
308[tls]
309cert = "/etc/vcon-server/cert.pem"
310key = "/etc/vcon-server/key.pem"
311"#;
312
313    #[test]
314    fn periods_and_limits_have_defaults() {
315        let config = parse(LEAST).expect("parses");
316        assert_eq!(
317            config.purge_interval,
318            Duration::from_secs(3600),
319            "the purge sweep runs hourly by default"
320        );
321        assert_eq!(
322            config.rotation_warn,
323            Duration::from_secs(30 * 86_400),
324            "an overdue rotation is warned of after 30 days by default"
325        );
326        assert_eq!(
327            config.body_limit,
328            16 << 20,
329            "a body may be 16 MiB by default"
330        );
331        assert_eq!(
332            config.inflated_limit,
333            64 << 20,
334            "a body may inflate to 64 MiB by default"
335        );
336        assert_eq!(
337            config.read_timeout,
338            Duration::from_secs(60),
339            "a request has 60 seconds to arrive by default"
340        );
341        assert_eq!(
342            config.mcp,
343            crate::mcp::Limits::DEFAULT,
344            "with no [mcp] table, the MCP endpoint takes its default limits"
345        );
346    }
347
348    /// A key left out of a table that is present takes its default; the
349    /// others keep their written values.
350    #[test]
351    fn each_limit_defaults_alone() {
352        let base = parse(FULL).expect("parses");
353        for (line, default) in [
354            (
355                "body = 10485760\n",
356                Config {
357                    body_limit: 16 << 20,
358                    ..base.clone()
359                },
360            ),
361            (
362                "inflated = 104857600\n",
363                Config {
364                    inflated_limit: 64 << 20,
365                    ..base.clone()
366                },
367            ),
368            (
369                "read = 30\n",
370                Config {
371                    read_timeout: Duration::from_secs(60),
372                    ..base.clone()
373                },
374            ),
375            (
376                "purge_interval = 1800\n",
377                Config {
378                    purge_interval: Duration::from_secs(3600),
379                    ..base.clone()
380                },
381            ),
382            (
383                "rotation_warn = 604800\n",
384                Config {
385                    rotation_warn: Duration::from_secs(30 * 86_400),
386                    ..base.clone()
387                },
388            ),
389            (
390                "[limits.timeouts]\nread = 30\n",
391                Config {
392                    read_timeout: Duration::from_secs(60),
393                    ..base.clone()
394                },
395            ),
396            (
397                "calls_per_second = 20\n",
398                Config {
399                    mcp: crate::mcp::Limits {
400                        calls_per_second: 100,
401                        ..base.mcp
402                    },
403                    ..base.clone()
404                },
405            ),
406            (
407                "concurrent = 8\n",
408                Config {
409                    mcp: crate::mcp::Limits {
410                        concurrent: 100,
411                        ..base.mcp
412                    },
413                    ..base.clone()
414                },
415            ),
416            (
417                "response_bytes = 262144\n",
418                Config {
419                    mcp: crate::mcp::Limits {
420                        response_bytes: 1 << 20,
421                        ..base.mcp
422                    },
423                    ..base.clone()
424                },
425            ),
426        ] {
427            assert!(FULL.contains(line), "{line}");
428            assert_eq!(parse(&FULL.replace(line, "")), Ok(default), "{line}");
429        }
430    }
431
432    /// The defaults differ from the full file's values, so each test above
433    /// tells a default from a written value.
434    #[test]
435    fn the_full_file_sets_no_value_equal_to_its_default() {
436        let (full, least) = (parse(FULL).expect("full"), parse(LEAST).expect("least"));
437        assert_ne!(
438            full.purge_interval, least.purge_interval,
439            "FULL writes purge_interval as a value other than its default"
440        );
441        assert_ne!(
442            full.rotation_warn, least.rotation_warn,
443            "FULL writes rotation_warn as a value other than its default"
444        );
445        assert_ne!(
446            full.body_limit, least.body_limit,
447            "FULL writes limits.body as a value other than its default"
448        );
449        assert_ne!(
450            full.inflated_limit, least.inflated_limit,
451            "FULL writes limits.inflated as a value other than its default"
452        );
453        assert_ne!(
454            full.read_timeout, least.read_timeout,
455            "FULL writes limits.timeouts.read as a value other than its default"
456        );
457        assert_ne!(
458            full.mcp.calls_per_second, least.mcp.calls_per_second,
459            "FULL writes mcp.calls_per_second as a value other than its default"
460        );
461        assert_ne!(
462            full.mcp.concurrent, least.mcp.concurrent,
463            "FULL writes mcp.concurrent as a value other than its default"
464        );
465        assert_ne!(
466            full.mcp.response_bytes, least.mcp.response_bytes,
467            "FULL writes mcp.response_bytes as a value other than its default"
468        );
469    }
470
471    /// Each error names its key.
472    #[test]
473    fn every_missing_key_without_a_default_is_named() {
474        for (line, key) in [
475            ("listen = \"127.0.0.1:8443\"\n", "listen"),
476            ("data_dir = \"/var/lib/vcon-server\"\n", "data_dir"),
477            (
478                "master_key = \"/etc/vcon-server/master.key\"\n",
479                "master_key",
480            ),
481            ("cert = \"/etc/vcon-server/cert.pem\"\n", "cert"),
482            ("key = \"/etc/vcon-server/key.pem\"\n", "key"),
483        ] {
484            assert!(FULL.contains(line), "{line}");
485            let error = parse(&FULL.replace(line, "")).expect_err(key);
486            assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
487        }
488    }
489
490    #[test]
491    fn an_unknown_key_is_named() {
492        for (text, key) in [
493            (format!("colour = \"blue\"\n{FULL}"), "colour"),
494            (FULL.replace("read = 30", "read = 30\nwrite = 30"), "write"),
495            (FULL.replace("[tls]", "[tls]\nca = \"x\""), "ca"),
496            (
497                FULL.replace("[mcp]", "[mcp]\nresources = true"),
498                "resources",
499            ),
500        ] {
501            let error = parse(&text).expect_err(key);
502            assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
503        }
504    }
505
506    #[test]
507    fn a_bad_value_is_named() {
508        for (from, to, key) in [
509            ("\"127.0.0.1:8443\"", "\"localhost\"", "listen"),
510            ("\"18m\"", "\"7w\"", "retention_default"),
511            (
512                "purge_interval = 1800",
513                "purge_interval = 0",
514                "purge_interval",
515            ),
516            (
517                "rotation_warn = 604800",
518                "rotation_warn = 0",
519                "rotation_warn",
520            ),
521            ("body = 10485760", "body = 0", "limits.body"),
522            ("inflated = 104857600", "inflated = 0", "limits.inflated"),
523            (
524                "calls_per_second = 20",
525                "calls_per_second = 0",
526                "mcp.calls_per_second",
527            ),
528            ("concurrent = 8", "concurrent = 0", "mcp.concurrent"),
529            (
530                "response_bytes = 262144",
531                "response_bytes = 0",
532                "mcp.response_bytes",
533            ),
534            (
535                "concurrent = 8",
536                "concurrent = 4294967296",
537                "mcp.concurrent",
538            ),
539            ("read = 30", "read = 0", "limits.timeouts.read"),
540            ("read = 30", "read = -1", "read"),
541        ] {
542            let error = parse(&FULL.replace(from, to)).expect_err(key);
543            assert!(error.0.contains(key), "{key}: {error}");
544        }
545    }
546
547    /// The website's configuration page lists every key the full file
548    /// sets, each with the default the code gives it, and every example
549    /// file on the site reads.
550    mod site_page {
551        use super::*;
552
553        fn page(name: &str) -> String {
554            let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
555                .join("../../website/content/docs")
556                .join(name);
557            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
558        }
559
560        /// Every key in a TOML table, dotted.
561        fn keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
562            for (key, value) in table {
563                let name = format!("{prefix}{key}");
564                match value {
565                    toml::Value::Table(inner) => keys(inner, &format!("{name}."), out),
566                    _ => out.push(name),
567                }
568            }
569        }
570
571        /// The table row for `key`, by its first cell.
572        fn row<'a>(text: &'a str, key: &str) -> &'a str {
573            let start = format!("| `{key}` |");
574            let mut rows = text.lines().filter(|l| l.starts_with(&start));
575            let row = rows.next().unwrap_or_else(|| panic!("no row for {key}"));
576            assert!(rows.next().is_none(), "two rows for {key}");
577            row
578        }
579
580        #[test]
581        fn every_key_is_listed_with_its_default() {
582            let text = page("configuration.md");
583            let defaults = parse(LEAST).expect("least");
584            let mut all = Vec::new();
585            keys(&FULL.parse::<toml::Table>().expect("toml"), "", &mut all);
586            assert_eq!(all.len(), 15, "{all:?}");
587            for key in all {
588                let default = match key.as_str() {
589                    "listen" | "data_dir" | "master_key" | "tls.cert" | "tls.key" => {
590                        "required".to_owned()
591                    }
592                    "retention_default" => format!("`{}`", defaults.retention_default),
593                    "purge_interval" => format!("`{}`", defaults.purge_interval.as_secs()),
594                    "rotation_warn" => format!("`{}`", defaults.rotation_warn.as_secs()),
595                    "limits.body" => format!("`{}`", defaults.body_limit),
596                    "limits.inflated" => format!("`{}`", defaults.inflated_limit),
597                    "limits.timeouts.read" => format!("`{}`", defaults.read_timeout.as_secs()),
598                    "mcp.calls_per_second" => format!("`{}`", defaults.mcp.calls_per_second),
599                    "mcp.concurrent" => format!("`{}`", defaults.mcp.concurrent),
600                    "mcp.response_bytes" => format!("`{}`", defaults.mcp.response_bytes),
601                    "supported_critical" => {
602                        assert!(
603                            defaults.supported_critical.is_empty(),
604                            "the page says none, which holds only while the default is empty"
605                        );
606                        "none".to_owned()
607                    }
608                    other => panic!("a key this test does not know: {other}"),
609                };
610                let row = row(&text, &key);
611                let last = row
612                    .trim_end_matches('|')
613                    .rsplit('|')
614                    .next()
615                    .expect("a last cell")
616                    .trim();
617                assert!(
618                    last.starts_with(&default),
619                    "{key}: `{last}` is not {default}"
620                );
621            }
622        }
623
624        /// The limits no key sets are stated as the code fixes them.
625        #[test]
626        fn every_fixed_limit_is_listed_with_its_value() {
627            let text = page("configuration.md");
628            for (limit, value) in [
629                ("Signed layers in one body", vcon::read::MAX_SIGNED_LAYERS),
630                (
631                    "Arrays and objects open at once",
632                    vcon::read::MAX_JSON_DEPTH,
633                ),
634            ] {
635                let start = format!("| {limit} |");
636                let rows: Vec<&str> = text.lines().filter(|l| l.starts_with(&start)).collect();
637                assert_eq!(rows.len(), 1, "one row for {limit}");
638                let cell = rows[0].split('|').nth(2).expect("a value cell").trim();
639                assert_eq!(cell, format!("`{value}`"), "{limit}");
640            }
641        }
642
643        #[test]
644        fn every_example_file_on_the_site_reads() {
645            let mut examples = 0;
646            for name in ["configuration.md", "getting-started.md", "container.md"] {
647                let text = page(name);
648                for block in text.split("```toml\n").skip(1) {
649                    let toml = block.split("```").next().expect("a block");
650                    parse(toml).unwrap_or_else(|e| panic!("{name}: {e}\n{toml}"));
651                    examples += 1;
652                }
653            }
654            assert_eq!(
655                examples, 3,
656                "one example file on each page, so a block the split misses is caught"
657            );
658        }
659    }
660
661    #[test]
662    fn a_missing_file_is_named() {
663        let error = load(Path::new("/nonexistent/vcon-server.toml")).expect_err("missing");
664        assert!(error.0.contains("/nonexistent/vcon-server.toml"), "{error}");
665    }
666}