diff options
Diffstat (limited to 'src/config/file.rs')
| -rw-r--r-- | src/config/file.rs | 260 |
1 files changed, 260 insertions, 0 deletions
diff --git a/src/config/file.rs b/src/config/file.rs new file mode 100644 index 0000000..b6eb94d --- /dev/null +++ b/src/config/file.rs @@ -0,0 +1,260 @@ +//! 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. +//! +//! `unset = [options]` sets options back to their defaults, overriding includes and +//! earlier files; in JSON, `null` does the same for one option. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use tracing::debug; + +use super::import; +use super::{Entry, Layer, Origin, Overlay, Provenance, overlay, spec, unset_entry}; + +const INCLUDE: &str = "include"; +const INCLUDE_OPTIONAL: &str = "include-optional"; +const UNSET: &str = "unset"; + +/// The settings `path` makes, its includes resolved. +pub(super) fn load(path: &Path) -> Result<Layer> { + load_inner(path, &mut Vec::new()) +} + +fn load_inner(path: &Path, loading: &mut Vec<PathBuf>) -> Result<Layer> { + 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)?; + let unsets = take_paths(&mut root, UNSET, &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 { + overlay(&mut layer, name, entry, Overlay::IncludeOverInclude); + } + } + loading.pop(); + + for name in unsets { + if spec(&name).is_none() { + bail!( + "unknown option {name:?} in unset, in {}", + canonical.display() + ); + } + let entry = unset_entry(Provenance::File(canonical.clone())); + overlay(&mut layer, name, entry, Overlay::FileOverFile); + } + + 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 { + origin: Origin { + provenance: Provenance::File(canonical.clone()), + unset: value.is_null(), + imports, + }, + value, + }; + overlay(&mut layer, name, entry, Overlay::FileOverFile); + } + Ok(layer) +} + +fn parse(path: &Path) -> Result<JsonMap<String, JsonValue>> { + 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<String, JsonValue>, + key: &str, + path: &Path, +) -> Result<Vec<String>> { + 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<String, JsonValue>, + 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 unset() { + let dir = scratch("unset"); + write( + &dir, + "base.toml", + "alias = \"base\"\nneighbours = 8\nkeepalive-secs = 30\n", + ); + let toml = write( + &dir, + "local.toml", + "include = \"base.toml\"\nunset = [\"alias\"]\n", + ); + let layer = load(&toml).unwrap(); + assert!(layer["alias"].value.is_null() && layer["alias"].origin.unset); + assert_eq!(layer["neighbours"].value, json!(8)); + let json = write( + &dir, + "local.json", + r#"{ "include": "base.toml", "neighbours": null }"#, + ); + let layer = load(&json).unwrap(); + assert!(layer["neighbours"].origin.unset); + assert_eq!(layer["keepalive-secs"].value, json!(30)); + let unknown = write(&dir, "bad.toml", "unset = [\"nope\"]\n"); + assert!(load(&unknown).is_err()); + } + + #[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()); + } +} |
