1use 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#[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 pub retention_default: Retention,
26 pub purge_interval: Duration,
27 pub rotation_warn: Duration,
28 pub body_limit: usize,
30 pub inflated_limit: u64,
32 pub read_timeout: Duration,
34 pub supported_critical: BTreeSet<String>,
36}
37
38pub const PURGE_INTERVAL: Duration = Duration::from_secs(3600);
41
42pub const ROTATION_WARN: Duration = Duration::from_secs(30 * 86_400);
45
46pub const BODY_LIMIT: usize = 16 << 20;
51
52pub const INFLATED_LIMIT: u64 = 4 * BODY_LIMIT as u64;
56
57pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
60
61#[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
71pub 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
78pub 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
123fn 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#[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 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 #[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 #[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 #[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 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 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 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 #[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::MAX_SIGNED_LAYERS),
446 ("Arrays and objects open at once", vcon::MAX_JSON_DEPTH),
447 ] {
448 let start = format!("| {limit} |");
449 let rows: Vec<&str> = text.lines().filter(|l| l.starts_with(&start)).collect();
450 assert_eq!(rows.len(), 1, "one row for {limit}");
451 let cell = rows[0].split('|').nth(2).expect("a value cell").trim();
452 assert_eq!(cell, format!("`{value}`"), "{limit}");
453 }
454 }
455
456 #[test]
457 fn every_example_file_on_the_site_reads() {
458 let mut examples = 0;
459 for name in ["configuration.md", "getting-started.md"] {
460 let text = page(name);
461 for block in text.split("```toml\n").skip(1) {
462 let toml = block.split("```").next().expect("a block");
463 parse(toml).unwrap_or_else(|e| panic!("{name}: {e}\n{toml}"));
464 examples += 1;
465 }
466 }
467 assert_eq!(examples, 2);
468 }
469 }
470
471 #[test]
472 fn a_missing_file_is_named() {
473 let error = load(Path::new("/nonexistent/vcon-server.toml")).expect_err("missing");
474 assert!(error.0.contains("/nonexistent/vcon-server.toml"), "{error}");
475 }
476}