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,
21 pub tls_cert: PathBuf,
23 pub tls_key: PathBuf,
25 pub data_dir: PathBuf,
27 pub master_key: PathBuf,
29 pub retention_default: Retention,
31 pub purge_interval: Duration,
33 pub rotation_warn: Duration,
36 pub body_limit: usize,
38 pub inflated_limit: u64,
40 pub read_timeout: Duration,
42 pub supported_critical: BTreeSet<String>,
44 pub mcp: crate::mcp::Limits,
46}
47
48pub const PURGE_INTERVAL: Duration = Duration::from_secs(3600);
51
52pub const ROTATION_WARN: Duration = Duration::from_secs(30 * 86_400);
55
56pub const BODY_LIMIT: usize = 16 << 20;
61
62pub const INFLATED_LIMIT: u64 = 4 * BODY_LIMIT as u64;
66
67pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
70
71#[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
83pub 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
95pub 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
140fn 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
170fn 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#[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 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 #[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 #[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 #[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 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 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 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 #[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}