summaryrefslogtreecommitdiffstats
path: root/src/config/file.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config/file.rs')
-rw-r--r--src/config/file.rs260
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());
+ }
+}