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    /// Where the data is kept.
47    pub storage: Storage,
48    /// How `export parquet` reaches a bucket: the `[export]` table.
49    pub export: Export,
50}
51
52/// The `[export]` table.
53#[derive(Debug, Clone, PartialEq, Eq, Default)]
54pub struct Export {
55    /// How an `s3://` target is reached (`[export.s3]`); None takes
56    /// `[storage.s3]`'s service and keys.
57    pub s3: Option<ExportS3>,
58}
59
60/// How to reach the S3-compatible service an export writes to: every key
61/// of `[storage.s3]` but the bucket and prefix, which `--to` names, and
62/// `allow_versioning`, which only the server's own storage reads.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ExportS3 {
65    /// The service's URL; None is AWS S3 for the region.
66    pub endpoint: Option<String>,
67    /// The region, used in request signing.
68    pub region: String,
69    /// The bucket in the path, not in the host name.
70    pub path_style: bool,
71    /// The access key id; None uses the AWS default credential chain.
72    pub access_key_id: Option<String>,
73    /// The file holding the secret access key.
74    pub secret_access_key_file: Option<PathBuf>,
75    /// Accept an `http://` endpoint.
76    pub allow_http: bool,
77    /// PEM certificates of the authorities trusted for the endpoint's TLS
78    /// certificate, in place of the system's.
79    pub ca_file: Option<PathBuf>,
80    /// How long one request may take.
81    pub timeout: Duration,
82}
83
84impl ExportS3 {
85    /// The settings for `bucket` under `prefix` on this service.
86    #[must_use]
87    pub fn for_bucket(&self, bucket: &str, prefix: &str) -> S3Settings {
88        S3Settings {
89            endpoint: self.endpoint.clone(),
90            region: self.region.clone(),
91            bucket: bucket.to_owned(),
92            prefix: prefix.to_owned(),
93            path_style: self.path_style,
94            access_key_id: self.access_key_id.clone(),
95            secret_access_key_file: self.secret_access_key_file.clone(),
96            allow_http: self.allow_http,
97            ca_file: self.ca_file.clone(),
98            allow_versioning: false,
99            timeout: self.timeout,
100        }
101    }
102}
103
104impl From<&S3Settings> for ExportS3 {
105    /// The service and keys of `[storage.s3]`.
106    fn from(s: &S3Settings) -> ExportS3 {
107        ExportS3 {
108            endpoint: s.endpoint.clone(),
109            region: s.region.clone(),
110            path_style: s.path_style,
111            access_key_id: s.access_key_id.clone(),
112            secret_access_key_file: s.secret_access_key_file.clone(),
113            allow_http: s.allow_http,
114            ca_file: s.ca_file.clone(),
115            timeout: s.timeout,
116        }
117    }
118}
119
120/// Why the storage the configuration names cannot be used: the message says
121/// which setting or step.
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[must_use]
124pub struct StorageError(pub String);
125
126impl fmt::Display for StorageError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(&self.0)
129    }
130}
131
132impl std::error::Error for StorageError {}
133
134/// Where the server keeps its data: the `[storage]` table. A file without
135/// it keeps everything in `data_dir`.
136#[derive(Debug, Clone, PartialEq, Eq, Default)]
137pub struct Storage {
138    /// Where the sealed vCon objects are kept.
139    pub objects: ObjectStore,
140    /// Parquet storage, `[storage.parquet]`: the objects in the bucket are
141    /// packed into Parquet files. None: they stay objects.
142    pub parquet: Option<ParquetSettings>,
143}
144
145/// The `[storage.parquet]` table.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ParquetSettings {
148    /// Time from the end of one pack run to the start of the next.
149    pub pack_interval: Duration,
150    /// The largest sealed object packed, in bytes; a larger one stays an
151    /// object.
152    pub pack_limit: u64,
153    /// How long an older manifest, and a file a compaction stopped naming,
154    /// stays for readers outside the server.
155    pub reader_grace: Duration,
156}
157
158/// How long an older manifest, and a file a compaction stopped naming,
159/// stays for readers outside the server unless configured: 15 minutes.
160pub const READER_GRACE: Duration = Duration::from_secs(900);
161
162/// Time between pack runs unless configured.
163pub const PACK_INTERVAL: Duration = Duration::from_secs(300);
164
165/// The largest sealed object packed unless configured: 16 MiB, the default
166/// largest request body.
167pub const PACK_LIMIT: u64 = 16 << 20;
168
169/// Where the sealed vCon objects are kept: `storage.objects`.
170#[derive(Debug, Clone, PartialEq, Eq, Default)]
171pub enum ObjectStore {
172    /// Files under `data_dir`, beside each tenant's catalog.
173    #[default]
174    Local,
175    /// An S3-compatible bucket.
176    S3(S3Settings),
177}
178
179/// How to reach the bucket: the `[storage.s3]` table.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct S3Settings {
182    /// The service's URL; None is AWS S3 for the region.
183    pub endpoint: Option<String>,
184    /// The region, used in request signing.
185    pub region: String,
186    /// The bucket, which must exist.
187    pub bucket: String,
188    /// Written before `tenants/` in every object's name.
189    pub prefix: String,
190    /// The bucket in the path, not in the host name.
191    pub path_style: bool,
192    /// The access key id; None uses the AWS default credential chain.
193    pub access_key_id: Option<String>,
194    /// The file holding the secret access key.
195    pub secret_access_key_file: Option<PathBuf>,
196    /// Accept an `http://` endpoint.
197    pub allow_http: bool,
198    /// PEM certificates of the authorities trusted for the endpoint's
199    /// TLS certificate, in place of the system's; None trusts the system's.
200    pub ca_file: Option<PathBuf>,
201    /// Start even though the bucket has versioning on.
202    pub allow_versioning: bool,
203    /// How long one request may take.
204    pub timeout: Duration,
205}
206
207/// Time between purge sweeps unless configured: a version is purged
208/// within an hour of falling due.
209pub const PURGE_INTERVAL: Duration = Duration::from_secs(3600);
210
211/// Age of the oldest work waiting for key rotation before `serve` warns,
212/// unless configured: 30 days.
213pub const ROTATION_WARN: Duration = Duration::from_secs(30 * 86_400);
214
215/// The largest request body unless configured: 16 MiB. The largest of
216/// 34,885 vCon files measured (public datasets and recorded test calls) was
217/// 5,141,813 bytes, a two-minute recording held inline; nearly all of their
218/// recordings are referenced by URL instead.
219pub const BODY_LIMIT: usize = 16 << 20;
220
221/// The largest inflation unless configured: four times [`BODY_LIMIT`], so
222/// any vCon accepted plain is also accepted gzipped, with room left for
223/// gzipped payloads inside its signed layers.
224pub const INFLATED_LIMIT: u64 = 4 * BODY_LIMIT as u64;
225
226/// How long a request body may take to arrive unless configured: a body at
227/// [`BODY_LIMIT`] needs about 2.2 Mbit/s.
228pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
229
230/// A configuration that could not be read, naming the key at fault.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct ConfigError(pub String);
233
234impl fmt::Display for ConfigError {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.write_str(&self.0)
237    }
238}
239
240impl std::error::Error for ConfigError {}
241
242/// Reads the configuration file at `path`.
243///
244/// # Errors
245///
246/// A [`ConfigError`] naming the file if it cannot be read, or any error
247/// [`parse`] gives for its text.
248pub fn load(path: &Path) -> Result<Config, ConfigError> {
249    let text = std::fs::read_to_string(path)
250        .map_err(|e| ConfigError(format!("{}: {e}", path.display())))?;
251    parse(&text)
252}
253
254/// Reads a configuration from its TOML text.
255///
256/// # Errors
257///
258/// A [`ConfigError`] naming the key at fault: text that is not TOML, an
259/// unknown key, a required key missing, or a value of the wrong type or
260/// not more than zero.
261pub fn parse(text: &str) -> Result<Config, ConfigError> {
262    let raw: Raw = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
263    let retention_default = match raw.retention_default {
264        None => Retention::Years(7),
265        Some(text) => Retention::parse(&text).ok_or_else(|| {
266            ConfigError(format!(
267                "retention_default: {text:?} is not a period such as 7y, 18m or 90d"
268            ))
269        })?,
270    };
271    Ok(Config {
272        listen: raw.listen,
273        tls_cert: raw.tls.cert,
274        tls_key: raw.tls.key,
275        data_dir: raw.data_dir,
276        master_key: raw.master_key,
277        retention_default,
278        purge_interval: seconds("purge_interval", raw.purge_interval, PURGE_INTERVAL)?,
279        rotation_warn: seconds("rotation_warn", raw.rotation_warn, ROTATION_WARN)?,
280        body_limit: match raw.limits.body {
281            None => BODY_LIMIT,
282            Some(body) => usize::try_from(positive("limits.body", body)?)
283                .map_err(|_| ConfigError("limits.body is too large".to_owned()))?,
284        },
285        inflated_limit: match raw.limits.inflated {
286            None => INFLATED_LIMIT,
287            Some(inflated) => positive("limits.inflated", inflated)?,
288        },
289        read_timeout: seconds(
290            "limits.timeouts.read",
291            raw.limits.timeouts.read,
292            READ_TIMEOUT,
293        )?,
294        supported_critical: raw.supported_critical.into_iter().collect(),
295        mcp: mcp_limits(&raw.mcp)?,
296        storage: storage(raw.storage)?,
297        export: Export {
298            s3: raw.export.s3.map(export_s3).transpose()?,
299        },
300    })
301}
302
303/// The `[mcp]` table's limits, each key that is not written at its default.
304fn mcp_limits(raw: &RawMcp) -> Result<crate::mcp::Limits, ConfigError> {
305    let default = crate::mcp::Limits::DEFAULT;
306    let narrow = |key: &str, value: u64| {
307        u32::try_from(positive(key, value)?).map_err(|_| ConfigError(format!("{key} is too large")))
308    };
309    Ok(crate::mcp::Limits {
310        calls_per_second: match raw.calls_per_second {
311            None => default.calls_per_second,
312            Some(value) => narrow("mcp.calls_per_second", value)?,
313        },
314        concurrent: match raw.concurrent {
315            None => default.concurrent,
316            Some(value) => narrow("mcp.concurrent", value)?,
317        },
318        response_bytes: match raw.response_bytes {
319            None => default.response_bytes,
320            Some(value) => usize::try_from(positive("mcp.response_bytes", value)?)
321                .map_err(|_| ConfigError("mcp.response_bytes is too large".to_owned()))?,
322        },
323    })
324}
325
326/// How long one S3 request may take unless configured.
327pub const S3_TIMEOUT: Duration = Duration::from_secs(30);
328
329/// The `[storage]` table checked: an S3 table only with `objects = "s3"`,
330/// an `http://` endpoint only when allowed, and an access key id only with
331/// the file of its secret.
332fn storage(raw: RawStorage) -> Result<Storage, ConfigError> {
333    let objects = match (raw.objects.as_deref(), raw.s3) {
334        (None | Some("local"), None) => ObjectStore::Local,
335        (None | Some("local"), Some(_)) => {
336            return Err(ConfigError(
337                "storage.s3 is set but storage.objects is not \"s3\"".to_owned(),
338            ));
339        }
340        (Some("s3"), None) => {
341            return Err(ConfigError(
342                "storage.objects is \"s3\" but there is no storage.s3 table".to_owned(),
343            ));
344        }
345        (Some("s3"), Some(s3)) => ObjectStore::S3(s3_settings(s3)?),
346        (Some(other), _) => {
347            return Err(ConfigError(format!(
348                "storage.objects: {other:?} is not \"local\" or \"s3\""
349            )));
350        }
351    };
352    let parquet = match (&objects, raw.parquet) {
353        (_, None) => None,
354        (ObjectStore::S3(_), Some(p)) => Some(ParquetSettings {
355            pack_interval: seconds(
356                "storage.parquet.pack_interval",
357                p.pack_interval,
358                PACK_INTERVAL,
359            )?,
360            pack_limit: match p.pack_limit {
361                None => PACK_LIMIT,
362                Some(limit) => positive("storage.parquet.pack_limit", limit)?,
363            },
364            reader_grace: seconds("storage.parquet.reader_grace", p.reader_grace, READER_GRACE)?,
365        }),
366        (ObjectStore::Local, Some(_)) => {
367            return Err(ConfigError(
368                "storage.parquet packs the objects of a bucket, so it needs \
369                 storage.objects = \"s3\""
370                    .to_owned(),
371            ));
372        }
373    };
374    Ok(Storage { objects, parquet })
375}
376
377/// `[export.s3]` checked as `[storage.s3]` is, but for its bucket.
378fn export_s3(raw: RawExportS3) -> Result<ExportS3, ConfigError> {
379    check_service(
380        "export.s3",
381        raw.endpoint.as_deref(),
382        raw.allow_http,
383        raw.ca_file.is_some(),
384        raw.access_key_id.is_some(),
385        raw.secret_access_key_file.is_some(),
386    )?;
387    Ok(ExportS3 {
388        endpoint: raw.endpoint,
389        region: raw.region,
390        path_style: raw.path_style,
391        access_key_id: raw.access_key_id,
392        secret_access_key_file: raw.secret_access_key_file,
393        allow_http: raw.allow_http,
394        ca_file: raw.ca_file,
395        timeout: seconds("export.s3.timeout", raw.timeout, S3_TIMEOUT)?,
396    })
397}
398
399/// The rules for how a service is reached, the same for every table that
400/// names one: an `http://` endpoint only when allowed and never with a CA
401/// file, any other endpoint `https://`, and an access key id only with the
402/// file of its secret.
403fn check_service(
404    table: &str,
405    endpoint: Option<&str>,
406    allow_http: bool,
407    ca_file: bool,
408    access_key_id: bool,
409    secret_access_key_file: bool,
410) -> Result<(), ConfigError> {
411    if let Some(endpoint) = endpoint {
412        if endpoint.starts_with("http://") {
413            if ca_file {
414                return Err(ConfigError(format!(
415                    "{table}.ca_file is set, but {table}.endpoint {endpoint:?} is plain \
416                     HTTP, which uses no certificate"
417                )));
418            }
419            if !allow_http {
420                return Err(ConfigError(format!(
421                    "{table}.endpoint {endpoint:?} is plain HTTP; set {table}.allow_http = \
422                     true only for a test service on this host"
423                )));
424            }
425        } else if !endpoint.starts_with("https://") {
426            return Err(ConfigError(format!(
427                "{table}.endpoint {endpoint:?} is not an https:// URL"
428            )));
429        }
430    }
431    match (access_key_id, secret_access_key_file) {
432        (true, false) => Err(ConfigError(format!(
433            "{table}.access_key_id is set without {table}.secret_access_key_file"
434        ))),
435        (false, true) => Err(ConfigError(format!(
436            "{table}.secret_access_key_file is set without {table}.access_key_id"
437        ))),
438        _ => Ok(()),
439    }
440}
441
442fn s3_settings(raw: RawS3) -> Result<S3Settings, ConfigError> {
443    if raw.bucket.is_empty() {
444        return Err(ConfigError("storage.s3.bucket is empty".to_owned()));
445    }
446    if !raw.prefix.is_empty() && (raw.prefix.starts_with('/') || !raw.prefix.ends_with('/')) {
447        return Err(ConfigError(format!(
448            "storage.s3.prefix {:?} must not start with / and must end with /, \
449             such as \"server-a/\"",
450            raw.prefix
451        )));
452    }
453    check_service(
454        "storage.s3",
455        raw.endpoint.as_deref(),
456        raw.allow_http,
457        raw.ca_file.is_some(),
458        raw.access_key_id.is_some(),
459        raw.secret_access_key_file.is_some(),
460    )?;
461    Ok(S3Settings {
462        endpoint: raw.endpoint,
463        region: raw.region,
464        bucket: raw.bucket,
465        prefix: raw.prefix,
466        path_style: raw.path_style,
467        access_key_id: raw.access_key_id,
468        secret_access_key_file: raw.secret_access_key_file,
469        allow_http: raw.allow_http,
470        ca_file: raw.ca_file,
471        allow_versioning: raw.allow_versioning,
472        timeout: seconds("storage.s3.timeout", raw.timeout, S3_TIMEOUT)?,
473    })
474}
475
476fn positive(key: &str, value: u64) -> Result<u64, ConfigError> {
477    if value == 0 {
478        return Err(ConfigError(format!("{key} must be more than 0")));
479    }
480    Ok(value)
481}
482
483/// `value` in whole seconds, or `default` when it is not written.
484fn seconds(key: &str, value: Option<u64>, default: Duration) -> Result<Duration, ConfigError> {
485    match value {
486        None => Ok(default),
487        Some(value) => positive(key, value).map(Duration::from_secs),
488    }
489}
490
491/// The file as written, before its values are checked.
492#[derive(Deserialize)]
493#[serde(deny_unknown_fields)]
494struct Raw {
495    listen: SocketAddr,
496    tls: RawTls,
497    data_dir: PathBuf,
498    master_key: PathBuf,
499    retention_default: Option<String>,
500    purge_interval: Option<u64>,
501    rotation_warn: Option<u64>,
502    #[serde(default)]
503    limits: RawLimits,
504    #[serde(default)]
505    supported_critical: Vec<String>,
506    #[serde(default)]
507    mcp: RawMcp,
508    #[serde(default)]
509    storage: RawStorage,
510    #[serde(default)]
511    export: RawExport,
512}
513
514#[derive(Default, Deserialize)]
515#[serde(deny_unknown_fields)]
516struct RawExport {
517    s3: Option<RawExportS3>,
518}
519
520#[derive(Deserialize)]
521#[serde(deny_unknown_fields)]
522struct RawExportS3 {
523    endpoint: Option<String>,
524    region: String,
525    #[serde(default)]
526    path_style: bool,
527    access_key_id: Option<String>,
528    secret_access_key_file: Option<PathBuf>,
529    #[serde(default)]
530    allow_http: bool,
531    ca_file: Option<PathBuf>,
532    timeout: Option<u64>,
533}
534
535#[derive(Default, Deserialize)]
536#[serde(deny_unknown_fields)]
537struct RawStorage {
538    objects: Option<String>,
539    s3: Option<RawS3>,
540    parquet: Option<RawParquet>,
541}
542
543#[derive(Deserialize)]
544#[serde(deny_unknown_fields)]
545struct RawParquet {
546    pack_interval: Option<u64>,
547    pack_limit: Option<u64>,
548    reader_grace: Option<u64>,
549}
550
551#[derive(Deserialize)]
552#[serde(deny_unknown_fields)]
553struct RawS3 {
554    endpoint: Option<String>,
555    region: String,
556    bucket: String,
557    #[serde(default)]
558    prefix: String,
559    #[serde(default)]
560    path_style: bool,
561    access_key_id: Option<String>,
562    secret_access_key_file: Option<PathBuf>,
563    #[serde(default)]
564    allow_http: bool,
565    ca_file: Option<PathBuf>,
566    #[serde(default)]
567    allow_versioning: bool,
568    timeout: Option<u64>,
569}
570
571#[derive(Default, Deserialize)]
572#[serde(deny_unknown_fields)]
573struct RawMcp {
574    calls_per_second: Option<u64>,
575    concurrent: Option<u64>,
576    response_bytes: Option<u64>,
577}
578
579#[derive(Deserialize)]
580#[serde(deny_unknown_fields)]
581struct RawTls {
582    cert: PathBuf,
583    key: PathBuf,
584}
585
586#[derive(Default, Deserialize)]
587#[serde(deny_unknown_fields)]
588struct RawLimits {
589    body: Option<u64>,
590    inflated: Option<u64>,
591    #[serde(default)]
592    timeouts: RawTimeouts,
593}
594
595#[derive(Default, Deserialize)]
596#[serde(deny_unknown_fields)]
597struct RawTimeouts {
598    read: Option<u64>,
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    const FULL: &str = r#"
606listen = "127.0.0.1:8443"
607data_dir = "/var/lib/vcon-server"
608master_key = "/etc/vcon-server/master.key"
609retention_default = "18m"
610purge_interval = 1800
611rotation_warn = 604800
612supported_critical = ["known", "other"]
613
614[tls]
615cert = "/etc/vcon-server/cert.pem"
616key = "/etc/vcon-server/key.pem"
617
618[limits]
619body = 10485760
620inflated = 104857600
621
622[limits.timeouts]
623read = 30
624
625[mcp]
626calls_per_second = 20
627concurrent = 8
628response_bytes = 262144
629
630[storage]
631objects = "s3"
632
633[storage.s3]
634endpoint = "https://objects.example.com:9000"
635region = "eu-west-1"
636bucket = "vcon"
637prefix = "server-a/"
638path_style = true
639access_key_id = "vcon-server"
640secret_access_key_file = "/etc/vcon-server/s3.secret"
641allow_http = true
642ca_file = "/etc/vcon-server/s3-ca.pem"
643allow_versioning = true
644timeout = 60
645
646[storage.parquet]
647pack_interval = 600
648pack_limit = 8388608
649reader_grace = 1800
650
651[export.s3]
652endpoint = "https://analytics.example.com"
653region = "eu-west-2"
654path_style = true
655access_key_id = "vcon-export"
656secret_access_key_file = "/etc/vcon-server/export.secret"
657allow_http = true
658ca_file = "/etc/vcon-server/export-ca.pem"
659timeout = 120
660"#;
661
662    #[test]
663    fn a_full_configuration_is_read() {
664        assert_eq!(
665            parse(FULL),
666            Ok(Config {
667                listen: "127.0.0.1:8443".parse().expect("address"),
668                tls_cert: "/etc/vcon-server/cert.pem".into(),
669                tls_key: "/etc/vcon-server/key.pem".into(),
670                data_dir: "/var/lib/vcon-server".into(),
671                master_key: "/etc/vcon-server/master.key".into(),
672                retention_default: Retention::Months(18),
673                purge_interval: Duration::from_secs(1800),
674                rotation_warn: Duration::from_secs(604_800),
675                body_limit: 10_485_760,
676                inflated_limit: 104_857_600,
677                read_timeout: Duration::from_secs(30),
678                supported_critical: ["known", "other"].map(String::from).into(),
679                mcp: crate::mcp::Limits {
680                    calls_per_second: 20,
681                    concurrent: 8,
682                    response_bytes: 262_144,
683                },
684                storage: Storage {
685                    objects: ObjectStore::S3(S3Settings {
686                        endpoint: Some("https://objects.example.com:9000".to_owned()),
687                        region: "eu-west-1".to_owned(),
688                        bucket: "vcon".to_owned(),
689                        prefix: "server-a/".to_owned(),
690                        path_style: true,
691                        access_key_id: Some("vcon-server".to_owned()),
692                        secret_access_key_file: Some("/etc/vcon-server/s3.secret".into()),
693                        allow_http: true,
694                        ca_file: Some("/etc/vcon-server/s3-ca.pem".into()),
695                        allow_versioning: true,
696                        timeout: Duration::from_secs(60),
697                    }),
698                    parquet: Some(ParquetSettings {
699                        pack_interval: Duration::from_secs(600),
700                        pack_limit: 8_388_608,
701                        reader_grace: Duration::from_secs(1800),
702                    }),
703                },
704                export: Export {
705                    s3: Some(ExportS3 {
706                        endpoint: Some("https://analytics.example.com".to_owned()),
707                        region: "eu-west-2".to_owned(),
708                        path_style: true,
709                        access_key_id: Some("vcon-export".to_owned()),
710                        secret_access_key_file: Some("/etc/vcon-server/export.secret".into()),
711                        allow_http: true,
712                        ca_file: Some("/etc/vcon-server/export-ca.pem".into()),
713                        timeout: Duration::from_secs(120),
714                    }),
715                },
716            }),
717            "every key the file writes is read into its field"
718        );
719    }
720
721    #[test]
722    fn retention_and_supported_extensions_have_defaults() {
723        let text = FULL
724            .replace("retention_default = \"18m\"\n", "")
725            .replace("supported_critical = [\"known\", \"other\"]\n", "");
726        let config = parse(&text).expect("parses");
727        assert_eq!(
728            config.retention_default,
729            Retention::Years(7),
730            "with no retention_default, vCons are kept seven years"
731        );
732        assert_eq!(
733            config.supported_critical,
734            BTreeSet::new(),
735            "with no supported_critical, no extension is supported"
736        );
737    }
738
739    /// The configuration with only the keys that have no default.
740    const LEAST: &str = r#"
741listen = "127.0.0.1:8443"
742data_dir = "/var/lib/vcon-server"
743master_key = "/etc/vcon-server/master.key"
744
745[tls]
746cert = "/etc/vcon-server/cert.pem"
747key = "/etc/vcon-server/key.pem"
748"#;
749
750    #[test]
751    fn periods_and_limits_have_defaults() {
752        let config = parse(LEAST).expect("parses");
753        assert_eq!(
754            config.purge_interval,
755            Duration::from_secs(3600),
756            "the purge sweep runs hourly by default"
757        );
758        assert_eq!(
759            config.rotation_warn,
760            Duration::from_secs(30 * 86_400),
761            "an overdue rotation is warned of after 30 days by default"
762        );
763        assert_eq!(
764            config.body_limit,
765            16 << 20,
766            "a body may be 16 MiB by default"
767        );
768        assert_eq!(
769            config.inflated_limit,
770            64 << 20,
771            "a body may inflate to 64 MiB by default"
772        );
773        assert_eq!(
774            config.read_timeout,
775            Duration::from_secs(60),
776            "a request has 60 seconds to arrive by default"
777        );
778        assert_eq!(
779            config.mcp,
780            crate::mcp::Limits::DEFAULT,
781            "with no [mcp] table, the MCP endpoint takes its default limits"
782        );
783    }
784
785    /// A key left out of a table that is present takes its default; the
786    /// others keep their written values.
787    #[test]
788    fn each_limit_defaults_alone() {
789        let base = parse(FULL).expect("parses");
790        for (line, default) in [
791            (
792                "body = 10485760\n",
793                Config {
794                    body_limit: 16 << 20,
795                    ..base.clone()
796                },
797            ),
798            (
799                "inflated = 104857600\n",
800                Config {
801                    inflated_limit: 64 << 20,
802                    ..base.clone()
803                },
804            ),
805            (
806                "read = 30\n",
807                Config {
808                    read_timeout: Duration::from_secs(60),
809                    ..base.clone()
810                },
811            ),
812            (
813                "purge_interval = 1800\n",
814                Config {
815                    purge_interval: Duration::from_secs(3600),
816                    ..base.clone()
817                },
818            ),
819            (
820                "rotation_warn = 604800\n",
821                Config {
822                    rotation_warn: Duration::from_secs(30 * 86_400),
823                    ..base.clone()
824                },
825            ),
826            (
827                "[limits.timeouts]\nread = 30\n",
828                Config {
829                    read_timeout: Duration::from_secs(60),
830                    ..base.clone()
831                },
832            ),
833            (
834                "calls_per_second = 20\n",
835                Config {
836                    mcp: crate::mcp::Limits {
837                        calls_per_second: 100,
838                        ..base.mcp
839                    },
840                    ..base.clone()
841                },
842            ),
843            (
844                "concurrent = 8\n",
845                Config {
846                    mcp: crate::mcp::Limits {
847                        concurrent: 100,
848                        ..base.mcp
849                    },
850                    ..base.clone()
851                },
852            ),
853            (
854                "response_bytes = 262144\n",
855                Config {
856                    mcp: crate::mcp::Limits {
857                        response_bytes: 1 << 20,
858                        ..base.mcp
859                    },
860                    ..base.clone()
861                },
862            ),
863        ] {
864            assert!(FULL.contains(line), "{line}");
865            assert_eq!(parse(&FULL.replace(line, "")), Ok(default), "{line}");
866        }
867    }
868
869    /// The defaults differ from the full file's values, so each test above
870    /// tells a default from a written value.
871    #[test]
872    fn the_full_file_sets_no_value_equal_to_its_default() {
873        let (full, least) = (parse(FULL).expect("full"), parse(LEAST).expect("least"));
874        assert_ne!(
875            full.purge_interval, least.purge_interval,
876            "FULL writes purge_interval as a value other than its default"
877        );
878        assert_ne!(
879            full.rotation_warn, least.rotation_warn,
880            "FULL writes rotation_warn as a value other than its default"
881        );
882        assert_ne!(
883            full.body_limit, least.body_limit,
884            "FULL writes limits.body as a value other than its default"
885        );
886        assert_ne!(
887            full.inflated_limit, least.inflated_limit,
888            "FULL writes limits.inflated as a value other than its default"
889        );
890        assert_ne!(
891            full.read_timeout, least.read_timeout,
892            "FULL writes limits.timeouts.read as a value other than its default"
893        );
894        assert_ne!(
895            full.mcp.calls_per_second, least.mcp.calls_per_second,
896            "FULL writes mcp.calls_per_second as a value other than its default"
897        );
898        assert_ne!(
899            full.mcp.concurrent, least.mcp.concurrent,
900            "FULL writes mcp.concurrent as a value other than its default"
901        );
902        assert_ne!(
903            full.mcp.response_bytes, least.mcp.response_bytes,
904            "FULL writes mcp.response_bytes as a value other than its default"
905        );
906        assert_ne!(
907            full.storage, least.storage,
908            "FULL writes storage.objects as a value other than its default"
909        );
910        let parquet = full.storage.parquet.expect("FULL has storage.parquet");
911        let least_parquet = parquet_least();
912        for (key, differs) in [
913            (
914                "storage.parquet.pack_interval",
915                parquet.pack_interval != least_parquet.pack_interval,
916            ),
917            (
918                "storage.parquet.pack_limit",
919                parquet.pack_limit != least_parquet.pack_limit,
920            ),
921            (
922                "storage.parquet.reader_grace",
923                parquet.reader_grace != least_parquet.reader_grace,
924            ),
925        ] {
926            assert!(
927                differs,
928                "FULL writes {key} as a value other than its default"
929            );
930        }
931        let ObjectStore::S3(s3) = full.storage.objects else {
932            panic!("FULL keeps its objects in S3")
933        };
934        let least_s3 = s3_least();
935        for (key, differs) in [
936            ("storage.s3.endpoint", s3.endpoint != least_s3.endpoint),
937            ("storage.s3.prefix", s3.prefix != least_s3.prefix),
938            (
939                "storage.s3.path_style",
940                s3.path_style != least_s3.path_style,
941            ),
942            (
943                "storage.s3.access_key_id",
944                s3.access_key_id != least_s3.access_key_id,
945            ),
946            (
947                "storage.s3.secret_access_key_file",
948                s3.secret_access_key_file != least_s3.secret_access_key_file,
949            ),
950            (
951                "storage.s3.allow_http",
952                s3.allow_http != least_s3.allow_http,
953            ),
954            ("storage.s3.ca_file", s3.ca_file != least_s3.ca_file),
955            (
956                "storage.s3.allow_versioning",
957                s3.allow_versioning != least_s3.allow_versioning,
958            ),
959            ("storage.s3.timeout", s3.timeout != least_s3.timeout),
960        ] {
961            assert!(
962                differs,
963                "FULL writes {key} as a value other than its default"
964            );
965        }
966        let export = full.export.s3.expect("FULL has export.s3");
967        let least_export = parse(&format!("{LEAST}\n[export.s3]\nregion = \"us-east-1\"\n"))
968            .expect("least export")
969            .export
970            .s3
971            .expect("export.s3");
972        for (key, differs) in [
973            (
974                "export.s3.endpoint",
975                export.endpoint != least_export.endpoint,
976            ),
977            ("export.s3.region", export.region != least_export.region),
978            (
979                "export.s3.path_style",
980                export.path_style != least_export.path_style,
981            ),
982            (
983                "export.s3.access_key_id",
984                export.access_key_id != least_export.access_key_id,
985            ),
986            (
987                "export.s3.secret_access_key_file",
988                export.secret_access_key_file != least_export.secret_access_key_file,
989            ),
990            (
991                "export.s3.allow_http",
992                export.allow_http != least_export.allow_http,
993            ),
994            ("export.s3.ca_file", export.ca_file != least_export.ca_file),
995            ("export.s3.timeout", export.timeout != least_export.timeout),
996        ] {
997            assert!(
998                differs,
999                "FULL writes {key} as a value other than its default"
1000            );
1001        }
1002    }
1003
1004    /// The S3 settings with only the keys that have no default.
1005    fn s3_least() -> S3Settings {
1006        let text = format!(
1007            "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\n"
1008        );
1009        match parse(&text)
1010            .expect("the least S3 configuration reads")
1011            .storage
1012            .objects
1013        {
1014            ObjectStore::S3(s3) => s3,
1015            ObjectStore::Local => panic!("objects = \"s3\" keeps the objects in S3"),
1016        }
1017    }
1018
1019    #[test]
1020    fn without_a_storage_table_everything_is_local() {
1021        assert_eq!(
1022            parse(LEAST).expect("parses").storage,
1023            Storage {
1024                objects: ObjectStore::Local,
1025                parquet: None,
1026            },
1027            "a file written before storage backends keeps its meaning"
1028        );
1029    }
1030
1031    /// The Parquet settings of an empty `[storage.parquet]` table.
1032    fn parquet_least() -> ParquetSettings {
1033        let text = format!(
1034            "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\n\
1035             bucket = \"vcon\"\n[storage.parquet]\n"
1036        );
1037        parse(&text)
1038            .expect("the least Parquet configuration reads")
1039            .storage
1040            .parquet
1041            .expect("an empty [storage.parquet] turns Parquet storage on")
1042    }
1043
1044    #[test]
1045    fn parquet_settings_have_defaults() {
1046        assert_eq!(
1047            parquet_least(),
1048            ParquetSettings {
1049                pack_interval: PACK_INTERVAL,
1050                pack_limit: PACK_LIMIT,
1051                reader_grace: READER_GRACE,
1052            },
1053            "an empty table takes every default"
1054        );
1055        assert_eq!(READER_GRACE, Duration::from_secs(900), "15 minutes");
1056        assert_eq!(PACK_INTERVAL, Duration::from_secs(300), "five minutes");
1057        assert_eq!(PACK_LIMIT, 16 << 20, "16 MiB");
1058    }
1059
1060    #[test]
1061    fn s3_settings_have_defaults() {
1062        assert_eq!(
1063            s3_least(),
1064            S3Settings {
1065                endpoint: None,
1066                region: "us-east-1".to_owned(),
1067                bucket: "vcon".to_owned(),
1068                prefix: String::new(),
1069                path_style: false,
1070                access_key_id: None,
1071                secret_access_key_file: None,
1072                allow_http: false,
1073                ca_file: None,
1074                allow_versioning: false,
1075                timeout: Duration::from_secs(30),
1076            },
1077            "only region and bucket are required"
1078        );
1079    }
1080
1081    /// `[export.s3]` takes `[storage.s3]`'s keys but the bucket, the
1082    /// prefix and `allow_versioning`, with the same defaults and rules,
1083    /// each refusal naming its own table.
1084    #[test]
1085    fn export_s3_settings_are_read_and_checked_as_storage_s3_is() {
1086        let export = |extra: &str| format!("{LEAST}\n[export.s3]\nregion = \"us-east-1\"\n{extra}");
1087        assert_eq!(
1088            parse(LEAST).expect("least").export,
1089            Export::default(),
1090            "no table"
1091        );
1092        assert_eq!(
1093            parse(&export("")).expect("least export").export.s3,
1094            Some(ExportS3 {
1095                endpoint: None,
1096                region: "us-east-1".to_owned(),
1097                path_style: false,
1098                access_key_id: None,
1099                secret_access_key_file: None,
1100                allow_http: false,
1101                ca_file: None,
1102                timeout: S3_TIMEOUT,
1103            }),
1104            "only region is required"
1105        );
1106        for (extra, key) in [
1107            (
1108                "endpoint = \"http://127.0.0.1:9000\"\n",
1109                "export.s3.allow_http",
1110            ),
1111            ("endpoint = \"objects.example.com\"\n", "export.s3.endpoint"),
1112            (
1113                "access_key_id = \"vcon\"\n",
1114                "export.s3.secret_access_key_file",
1115            ),
1116            (
1117                "secret_access_key_file = \"/s\"\n",
1118                "export.s3.access_key_id",
1119            ),
1120            ("timeout = 0\n", "export.s3.timeout"),
1121            (
1122                "endpoint = \"http://127.0.0.1:9000\"\nallow_http = true\nca_file = \"/c.pem\"\n",
1123                "export.s3.ca_file",
1124            ),
1125            ("bucket = \"vcon\"\n", "bucket"),
1126            ("prefix = \"a/\"\n", "prefix"),
1127            ("allow_versioning = true\n", "allow_versioning"),
1128        ] {
1129            let ConfigError(message) = parse(&export(extra)).expect_err(extra);
1130            assert!(message.contains(key), "{extra}: {message}");
1131        }
1132        assert!(
1133            parse(&format!("{LEAST}\n[export.s3]\n"))
1134                .expect_err("no region")
1135                .0
1136                .contains("region"),
1137            "region is required"
1138        );
1139    }
1140
1141    /// The settings an `s3://` target uses: this service, with `--to`'s
1142    /// bucket and prefix; from `[storage.s3]`, its service and keys.
1143    #[test]
1144    fn an_export_service_names_its_bucket_and_prefix() {
1145        let full = parse(FULL).expect("full");
1146        let export = full.export.s3.expect("export.s3");
1147        let s = export.for_bucket("reports", "vcon/");
1148        assert_eq!(
1149            (s.bucket.as_str(), s.prefix.as_str()),
1150            ("reports", "vcon/"),
1151            "the target's bucket and prefix"
1152        );
1153        assert_eq!(s.endpoint, export.endpoint, "the export's endpoint");
1154        assert_eq!(s.region, export.region, "the export's region");
1155        assert_eq!(
1156            s.access_key_id, export.access_key_id,
1157            "the export's access key id"
1158        );
1159        assert_eq!(
1160            s.secret_access_key_file, export.secret_access_key_file,
1161            "the export's secret file"
1162        );
1163        assert_eq!(s.ca_file, export.ca_file, "the export's CA file");
1164        assert_eq!(
1165            (s.path_style, s.allow_http),
1166            (true, true),
1167            "FULL sets path_style and allow_http"
1168        );
1169        assert_eq!(s.timeout, Duration::from_secs(120), "the export's timeout");
1170        assert!(!s.allow_versioning, "never read for an export");
1171        let ObjectStore::S3(storage) = full.storage.objects else {
1172            panic!("FULL keeps its objects in S3")
1173        };
1174        let from = ExportS3::from(&storage).for_bucket(&storage.bucket, &storage.prefix);
1175        assert_eq!(
1176            from,
1177            S3Settings {
1178                allow_versioning: false,
1179                ..storage
1180            },
1181            "storage.s3's service and keys, whole"
1182        );
1183    }
1184
1185    #[test]
1186    fn s3_settings_that_do_not_hold_together_are_named() {
1187        let s3 = |extra: &str| {
1188            format!(
1189                "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\n{extra}"
1190            )
1191        };
1192        for (text, key) in [
1193            (
1194                format!("{LEAST}\n[storage]\nobjects = \"s3\"\n"),
1195                "storage.s3",
1196            ),
1197            (
1198                format!("{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nbucket = \"vcon\"\n"),
1199                "region",
1200            ),
1201            (
1202                format!(
1203                    "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\n"
1204                ),
1205                "bucket",
1206            ),
1207            (
1208                s3("endpoint = \"http://127.0.0.1:9000\"\n"),
1209                "storage.s3.allow_http",
1210            ),
1211            (
1212                s3("endpoint = \"objects.example.com\"\n"),
1213                "storage.s3.endpoint",
1214            ),
1215            (
1216                s3("access_key_id = \"vcon-server\"\n"),
1217                "storage.s3.secret_access_key_file",
1218            ),
1219            (
1220                s3("secret_access_key_file = \"/etc/vcon-server/s3.secret\"\n"),
1221                "storage.s3.access_key_id",
1222            ),
1223            (s3("timeout = 0\n"), "storage.s3.timeout"),
1224            (
1225                s3(
1226                    "endpoint = \"http://127.0.0.1:9000\"\nallow_http = true\nca_file = \"/etc/vcon-server/s3-ca.pem\"\n",
1227                ),
1228                "storage.s3.ca_file",
1229            ),
1230            (s3("prefix = \"/server-a/\"\n"), "storage.s3.prefix"),
1231            (s3("prefix = \"server-a\"\n"), "storage.s3.prefix"),
1232            (
1233                s3("bucket = \"\"\n").replace("bucket = \"vcon\"\n", ""),
1234                "storage.s3.bucket",
1235            ),
1236            (
1237                format!("{LEAST}\n[storage]\nobjects = \"tape\"\n"),
1238                "objects",
1239            ),
1240            (format!("{LEAST}\n[storage.parquet]\n"), "storage.parquet"),
1241            (
1242                format!("{LEAST}\n[storage]\nobjects = \"local\"\n[storage.parquet]\n"),
1243                "storage.parquet",
1244            ),
1245            (
1246                s3("[storage.parquet]\npack_interval = 0\n"),
1247                "storage.parquet.pack_interval",
1248            ),
1249            (
1250                s3("[storage.parquet]\npack_limit = 0\n"),
1251                "storage.parquet.pack_limit",
1252            ),
1253            (
1254                s3("[storage.parquet]\nreader_grace = 0\n"),
1255                "storage.parquet.reader_grace",
1256            ),
1257            (
1258                format!(
1259                    "{LEAST}\n[storage]\nobjects = \"local\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\n"
1260                ),
1261                "storage.s3",
1262            ),
1263        ] {
1264            let error = parse(&text).expect_err(key);
1265            assert!(error.0.contains(key), "the error names {key}: {error}");
1266        }
1267    }
1268
1269    #[test]
1270    fn an_http_endpoint_reads_when_allowed() {
1271        let text = format!(
1272            "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\nendpoint = \"http://127.0.0.1:9000\"\nallow_http = true\n"
1273        );
1274        assert!(
1275            parse(&text).is_ok(),
1276            "allow_http lets a test container's plain endpoint through"
1277        );
1278    }
1279
1280    /// Each error names its key.
1281    #[test]
1282    fn every_missing_key_without_a_default_is_named() {
1283        for (line, key) in [
1284            ("listen = \"127.0.0.1:8443\"\n", "listen"),
1285            ("data_dir = \"/var/lib/vcon-server\"\n", "data_dir"),
1286            (
1287                "master_key = \"/etc/vcon-server/master.key\"\n",
1288                "master_key",
1289            ),
1290            ("cert = \"/etc/vcon-server/cert.pem\"\n", "cert"),
1291            ("key = \"/etc/vcon-server/key.pem\"\n", "key"),
1292        ] {
1293            assert!(FULL.contains(line), "{line}");
1294            let error = parse(&FULL.replace(line, "")).expect_err(key);
1295            assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
1296        }
1297    }
1298
1299    #[test]
1300    fn an_unknown_key_is_named() {
1301        for (text, key) in [
1302            (format!("colour = \"blue\"\n{FULL}"), "colour"),
1303            (FULL.replace("read = 30", "read = 30\nwrite = 30"), "write"),
1304            (FULL.replace("[tls]", "[tls]\nca = \"x\""), "ca"),
1305            (
1306                FULL.replace("[mcp]", "[mcp]\nresources = true"),
1307                "resources",
1308            ),
1309        ] {
1310            let error = parse(&text).expect_err(key);
1311            assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
1312        }
1313    }
1314
1315    #[test]
1316    fn a_bad_value_is_named() {
1317        for (from, to, key) in [
1318            ("\"127.0.0.1:8443\"", "\"localhost\"", "listen"),
1319            ("\"18m\"", "\"7w\"", "retention_default"),
1320            (
1321                "purge_interval = 1800",
1322                "purge_interval = 0",
1323                "purge_interval",
1324            ),
1325            (
1326                "rotation_warn = 604800",
1327                "rotation_warn = 0",
1328                "rotation_warn",
1329            ),
1330            ("body = 10485760", "body = 0", "limits.body"),
1331            ("inflated = 104857600", "inflated = 0", "limits.inflated"),
1332            (
1333                "calls_per_second = 20",
1334                "calls_per_second = 0",
1335                "mcp.calls_per_second",
1336            ),
1337            ("concurrent = 8", "concurrent = 0", "mcp.concurrent"),
1338            (
1339                "response_bytes = 262144",
1340                "response_bytes = 0",
1341                "mcp.response_bytes",
1342            ),
1343            (
1344                "concurrent = 8",
1345                "concurrent = 4294967296",
1346                "mcp.concurrent",
1347            ),
1348            ("read = 30", "read = 0", "limits.timeouts.read"),
1349            ("read = 30", "read = -1", "read"),
1350        ] {
1351            let error = parse(&FULL.replace(from, to)).expect_err(key);
1352            assert!(error.0.contains(key), "{key}: {error}");
1353        }
1354    }
1355
1356    /// The website's configuration page lists every key the full file
1357    /// sets, each with the default the code gives it, and every example
1358    /// file on the site reads.
1359    mod site_page {
1360        use super::*;
1361
1362        fn page(name: &str) -> String {
1363            let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1364                .join("../../website/content/docs")
1365                .join(name);
1366            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
1367        }
1368
1369        /// Every key in a TOML table, dotted.
1370        fn keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
1371            for (key, value) in table {
1372                let name = format!("{prefix}{key}");
1373                match value {
1374                    toml::Value::Table(inner) => keys(inner, &format!("{name}."), out),
1375                    _ => out.push(name),
1376                }
1377            }
1378        }
1379
1380        /// The table row for `key`, by its first cell.
1381        fn row<'a>(text: &'a str, key: &str) -> &'a str {
1382            let start = format!("| `{key}` |");
1383            let mut rows = text.lines().filter(|l| l.starts_with(&start));
1384            let row = rows.next().unwrap_or_else(|| panic!("no row for {key}"));
1385            assert!(rows.next().is_none(), "two rows for {key}");
1386            row
1387        }
1388
1389        #[test]
1390        fn every_key_is_listed_with_its_default() {
1391            let text = page("configuration.md");
1392            let defaults = parse(LEAST).expect("least");
1393            let mut all = Vec::new();
1394            keys(&FULL.parse::<toml::Table>().expect("toml"), "", &mut all);
1395            assert_eq!(all.len(), 38, "{all:?}");
1396            for key in all {
1397                let default = match key.as_str() {
1398                    "listen" | "data_dir" | "master_key" | "tls.cert" | "tls.key" => {
1399                        "required".to_owned()
1400                    }
1401                    "retention_default" => format!("`{}`", defaults.retention_default),
1402                    "purge_interval" => format!("`{}`", defaults.purge_interval.as_secs()),
1403                    "rotation_warn" => format!("`{}`", defaults.rotation_warn.as_secs()),
1404                    "limits.body" => format!("`{}`", defaults.body_limit),
1405                    "limits.inflated" => format!("`{}`", defaults.inflated_limit),
1406                    "limits.timeouts.read" => format!("`{}`", defaults.read_timeout.as_secs()),
1407                    "mcp.calls_per_second" => format!("`{}`", defaults.mcp.calls_per_second),
1408                    "mcp.concurrent" => format!("`{}`", defaults.mcp.concurrent),
1409                    "mcp.response_bytes" => format!("`{}`", defaults.mcp.response_bytes),
1410                    "storage.objects" => "`local`".to_owned(),
1411                    "storage.s3.region" | "storage.s3.bucket" => "required with `s3`".to_owned(),
1412                    "storage.s3.endpoint"
1413                    | "storage.s3.access_key_id"
1414                    | "storage.s3.secret_access_key_file"
1415                    | "storage.s3.ca_file" => "none".to_owned(),
1416                    "storage.s3.prefix" => "empty".to_owned(),
1417                    "storage.s3.path_style" => format!("`{}`", s3_least().path_style),
1418                    "storage.s3.allow_http" => format!("`{}`", s3_least().allow_http),
1419                    "storage.s3.allow_versioning" => {
1420                        format!("`{}`", s3_least().allow_versioning)
1421                    }
1422                    "storage.s3.timeout" => format!("`{}`", s3_least().timeout.as_secs()),
1423                    "storage.parquet.pack_interval" => {
1424                        format!("`{}`", parquet_least().pack_interval.as_secs())
1425                    }
1426                    "storage.parquet.pack_limit" => format!("`{}`", parquet_least().pack_limit),
1427                    "storage.parquet.reader_grace" => {
1428                        format!("`{}`", parquet_least().reader_grace.as_secs())
1429                    }
1430                    "export.s3.region" => "required with `export.s3`".to_owned(),
1431                    "export.s3.endpoint"
1432                    | "export.s3.access_key_id"
1433                    | "export.s3.secret_access_key_file"
1434                    | "export.s3.ca_file" => "none".to_owned(),
1435                    "export.s3.path_style" => format!("`{}`", s3_least().path_style),
1436                    "export.s3.allow_http" => format!("`{}`", s3_least().allow_http),
1437                    "export.s3.timeout" => format!("`{}`", S3_TIMEOUT.as_secs()),
1438                    "supported_critical" => {
1439                        assert!(
1440                            defaults.supported_critical.is_empty(),
1441                            "the page says none, which holds only while the default is empty"
1442                        );
1443                        "none".to_owned()
1444                    }
1445                    other => panic!("a key this test does not know: {other}"),
1446                };
1447                let row = row(&text, &key);
1448                let last = row
1449                    .trim_end_matches('|')
1450                    .rsplit('|')
1451                    .next()
1452                    .expect("a last cell")
1453                    .trim();
1454                assert!(
1455                    last.starts_with(&default),
1456                    "{key}: `{last}` is not {default}"
1457                );
1458            }
1459        }
1460
1461        /// The limits no key sets are stated as the code fixes them.
1462        #[test]
1463        fn every_fixed_limit_is_listed_with_its_value() {
1464            let text = page("configuration.md");
1465            for (limit, value) in [
1466                ("Signed layers in one body", vcon::read::MAX_SIGNED_LAYERS),
1467                (
1468                    "Arrays and objects open at once",
1469                    vcon::read::MAX_JSON_DEPTH,
1470                ),
1471            ] {
1472                let start = format!("| {limit} |");
1473                let rows: Vec<&str> = text.lines().filter(|l| l.starts_with(&start)).collect();
1474                assert_eq!(rows.len(), 1, "one row for {limit}");
1475                let cell = rows[0].split('|').nth(2).expect("a value cell").trim();
1476                assert_eq!(cell, format!("`{value}`"), "{limit}");
1477            }
1478        }
1479
1480        #[test]
1481        fn every_example_file_on_the_site_reads() {
1482            let mut examples = 0;
1483            for name in ["configuration.md", "getting-started.md", "container.md"] {
1484                let text = page(name);
1485                for block in text.split("```toml\n").skip(1) {
1486                    let toml = block.split("```").next().expect("a block");
1487                    parse(toml).unwrap_or_else(|e| panic!("{name}: {e}\n{toml}"));
1488                    examples += 1;
1489                }
1490            }
1491            assert_eq!(
1492                examples, 5,
1493                "three on the configuration page, one each on getting started and the \
1494                 container page, so a block the split misses is caught"
1495            );
1496        }
1497    }
1498
1499    #[test]
1500    fn a_missing_file_is_named() {
1501        let error = load(Path::new("/nonexistent/vcon-server.toml")).expect_err("missing");
1502        assert!(error.0.contains("/nonexistent/vcon-server.toml"), "{error}");
1503    }
1504}