//! Configuration files: TOML (`.toml`) for people, JSON (`.json`) for generated //! configuration, with the same keys: the long option names. Nested tables join their //! keys with `-`, so `[mycelium] key-file = …` is `mycelium-key-file`. //! //! `include = [paths]` and `include-optional = [paths]` load other files first //! (relative to this one); this file's own values win over theirs, and later includes //! over earlier ones. A missing optional include is skipped; a cycle is an error. use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; use serde_json::{Map as JsonMap, Value as JsonValue}; use tracing::{debug, warn}; use super::import; use super::{Entry, Layer, Origin, Provenance, spec}; const INCLUDE: &str = "include"; const INCLUDE_OPTIONAL: &str = "include-optional"; /// The settings `path` makes, its includes resolved. pub(super) fn load(path: &Path) -> Result { load_inner(path, &mut Vec::new()) } fn load_inner(path: &Path, loading: &mut Vec) -> Result { let canonical = path .canonicalize() .with_context(|| format!("configuration file {}", path.display()))?; if loading.contains(&canonical) { bail!("{} includes itself (through {:?})", path.display(), loading); } let dir = canonical .parent() .map(Path::to_owned) .unwrap_or_else(|| PathBuf::from("/")); let mut root = parse(&canonical)?; let includes = take_paths(&mut root, INCLUDE, &canonical)?; let optional = take_paths(&mut root, INCLUDE_OPTIONAL, &canonical)?; loading.push(canonical.clone()); let mut layer = Layer::new(); for (include, required) in includes .into_iter() .map(|p| (p, true)) .chain(optional.into_iter().map(|p| (p, false))) { let include = dir.join(include); if !required && !include.exists() { debug!(file = %include.display(), "Optional configuration file is missing; skipped"); continue; } let included = load_inner(&include, loading) .with_context(|| format!("included from {}", canonical.display()))?; for (name, entry) in included { if let Some(previous) = layer.get(&name) { warn!( option = %name, value = %entry.shown(), from = %entry.origin, overrides = %previous.shown(), overridden_from = %previous.origin, "Two included configuration files set the same option; the later one wins" ); } layer.insert(name, entry); } } loading.pop(); let mut own = Vec::new(); flatten(&root, "", &dir, &mut own).with_context(|| format!("in {}", canonical.display()))?; for (name, value) in own { let spec = spec(&name) .ok_or_else(|| anyhow!("unknown option {name:?} in {}", canonical.display()))?; let (value, imports) = super::resolve_file_value(spec, value, &dir) .with_context(|| format!("option {name} in {}", canonical.display()))?; let entry = Entry { value, origin: Origin { provenance: Provenance::File(canonical.clone()), imports, }, }; if let Some(previous) = layer.get(&name) { debug!( option = %name, from = %entry.origin, overrides = %previous.origin, "Configuration file overrides an included one" ); } layer.insert(name, entry); } Ok(layer) } fn parse(path: &Path) -> Result> { let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; let value: JsonValue = match path.extension().and_then(|e| e.to_str()) { Some("toml") => { let value: toml::Value = toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; serde_json::to_value(value).with_context(|| format!("converting {}", path.display()))? } Some("json") => { serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))? } _ => bail!( "{}: unknown configuration format (use .toml or .json)", path.display() ), }; match value { JsonValue::Object(map) => Ok(map), _ => bail!("{}: the top level must be a table", path.display()), } } fn take_paths( root: &mut JsonMap, key: &str, path: &Path, ) -> Result> { match root.remove(key) { None => Ok(Vec::new()), Some(JsonValue::String(single)) => Ok(vec![single]), Some(JsonValue::Array(items)) => items .into_iter() .map(|item| match item { JsonValue::String(item) => Ok(item), other => Err(anyhow!( "{}: {key} entries must be paths, not {other}", path.display() )), }) .collect(), Some(other) => bail!( "{}: {key} must be a list of paths, not {other}", path.display() ), } } /// Option names and values, with nested tables joined by `-`. Importer tables are /// values, not nesting. fn flatten( table: &JsonMap, prefix: &str, dir: &Path, out: &mut Vec<(String, JsonValue)>, ) -> Result<()> { for (key, value) in table { let name = if prefix.is_empty() { key.clone() } else { format!("{prefix}-{key}") }; match value { JsonValue::Object(nested) if import::parse_table(nested, dir)?.is_none() => { flatten(nested, &name, dir, out)? } _ => out.push((name, value.clone())), } } Ok(()) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn write(dir: &Path, name: &str, text: &str) -> PathBuf { let path = dir.join(name); std::fs::write(&path, text).unwrap(); path } fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("continuum-config-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn includes_tables_and_importers() { let dir = scratch("includes"); write(&dir, "key-path", "/run/secrets/swarm-key\n"); write( &dir, "public.toml", "neighbours = 8\nalias = \"public\"\n[mycelium]\npeers = [\"tls://a:9651\"]\n", ); write( &dir, "secrets.json", r#"{ "swarm-key-file": { "file": "key-path" } }"#, ); let main = write( &dir, "main.toml", "include = [\"public.toml\", \"secrets.json\"]\ninclude-optional = [\"missing.toml\"]\nalias = \"main\"\n", ); let layer = load(&main).unwrap(); assert_eq!(layer["neighbours"].value, json!(8)); assert_eq!(layer["alias"].value, json!("main")); assert_eq!(layer["mycelium-peers"].value, json!(["tls://a:9651"])); assert_eq!( layer["swarm-key-file"].value, json!("/run/secrets/swarm-key") ); assert!(layer["swarm-key-file"].origin.is_imported()); assert!(layer["alias"].origin.to_string().ends_with("main.toml")); } #[test] fn errors() { let dir = scratch("errors"); let unknown = write(&dir, "unknown.toml", "nieghbours = 3\n"); assert!(load(&unknown).is_err()); let cycle = write(&dir, "cycle.toml", "include = [\"cycle.toml\"]\n"); assert!(load(&cycle).is_err()); let missing = write(&dir, "missing.toml", "include = [\"nope.toml\"]\n"); assert!(load(&missing).is_err()); let format = write(&dir, "config.yaml", "alias: x\n"); assert!(load(&format).is_err()); } }