diff options
| author | tv <tv@krebsco.de> | 2026-10-24 15:38:32 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-24 15:38:32 +0200 |
| commit | eb9cc70985a27fe16788a885b854fb514234c43a (patch) | |
| tree | 6dfc192abea479f4690766c06b1ffa64d7e95f28 /src/config | |
| parent | feb349ee09aa2151c421840bec521719c6149dcb (diff) | |
allow unsetting config options
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/file.rs | 73 | ||||
| -rw-r--r-- | src/config/mod.rs | 237 |
2 files changed, 264 insertions, 46 deletions
diff --git a/src/config/file.rs b/src/config/file.rs index fea601b..b6eb94d 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -5,18 +5,22 @@ //! `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, warn}; +use tracing::debug; use super::import; -use super::{Entry, Layer, Origin, Provenance, spec}; +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> { @@ -38,6 +42,7 @@ fn load_inner(path: &Path, loading: &mut Vec<PathBuf>) -> Result<Layer> { 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(); @@ -54,21 +59,22 @@ fn load_inner(path: &Path, loading: &mut Vec<PathBuf>) -> Result<Layer> { 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); + 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 { @@ -77,21 +83,14 @@ fn load_inner(path: &Path, loading: &mut Vec<PathBuf>) -> Result<Layer> { 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()), + unset: value.is_null(), imports, }, + value, }; - 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); + overlay(&mut layer, name, entry, Overlay::FileOverFile); } Ok(layer) } @@ -219,6 +218,34 @@ mod tests { } #[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"); diff --git a/src/config/mod.rs b/src/config/mod.rs index e62b2b6..4f6c5e0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -22,7 +22,7 @@ use clap::parser::ValueSource; use clap::{Arg, ArgAction, ArgMatches, Command}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use import::{Imported, Raw}; @@ -309,6 +309,9 @@ pub(crate) enum Provenance { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Origin { pub(crate) provenance: Provenance, + /// The option was unset here (back to its default): `--unset`, `unset = [...]` in a + /// file, or `null` in a JSON file. + pub(crate) unset: bool, /// Importers the value (or some list elements) came from, e.g. `env:HOSTNAME`. pub(crate) imports: Vec<String>, } @@ -325,6 +328,9 @@ impl std::fmt::Display for Origin { Provenance::File(path) => write!(f, "{}", path.display())?, Provenance::Argument => write!(f, "the command line")?, } + if self.unset { + write!(f, " (unset)")?; + } if self.is_imported() { write!(f, " ({})", self.imports.join(", "))?; } @@ -341,7 +347,9 @@ pub(crate) struct Entry { impl Entry { /// The value for logs: imported values are never shown. pub(crate) fn shown(&self) -> String { - if self.origin.is_imported() { + if self.origin.unset { + "<default>".to_owned() + } else if self.origin.is_imported() { "<imported>".to_owned() } else { self.value.to_string() @@ -349,9 +357,118 @@ impl Entry { } } -/// Option name to value. +/// Option name to value. A `null` value unsets the option (back to its default). pub(crate) type Layer = BTreeMap<String, Entry>; +/// An unset of `name` from `provenance`. +fn unset_entry(provenance: Provenance) -> Entry { + Entry { + value: JsonValue::Null, + origin: Origin { + provenance, + unset: true, + imports: Vec::new(), + }, + } +} + +/// Options that can't be combined. A source that sets one side drops the other side's +/// values from earlier sources (lower precedence), so e.g. `--mycelium-rest-url` on the +/// command line overrides `mycelium-key-file` from a file. Both set in one source is an +/// error (`Settings::check`). +const CONFLICTS: &[(&[&str], &[&str])] = &[( + &["mycelium-rest-url"], + &[ + "mycelium-key-file", + "mycelium-network-key-file", + "mycelium-network-name", + "mycelium-tcp-listen-port", + ], +)]; + +/// The options `name` can't be combined with. +fn conflicting(name: &str) -> impl Iterator<Item = &'static str> + '_ { + CONFLICTS.iter().flat_map(move |(a, b)| { + let other: &'static [&'static str] = if a.contains(&name) { + b + } else if b.contains(&name) { + a + } else { + &[] + }; + other.iter().copied() + }) +} + +/// How a later source relates to the earlier ones it overrides (for logging). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Overlay { + /// A later include over an earlier include of the same file: usually a mistake. + IncludeOverInclude, + /// A file's own values over its includes, or a later `--config` over an earlier. + FileOverFile, + /// The command line over files. + ArgumentOverFile, +} + +/// Puts `entry` for `name` into `layer`, which holds earlier sources, logging what it +/// replaces and dropping earlier values it can't be combined with. +pub(crate) fn overlay(layer: &mut Layer, name: String, entry: Entry, how: Overlay) { + let log = |message: &str, option: &str, previous: &Entry| match how { + Overlay::IncludeOverInclude => warn!( + option, + value = %entry.shown(), + from = %entry.origin, + overrides = %previous.shown(), + overridden_from = %previous.origin, + "{message}" + ), + Overlay::FileOverFile => debug!( + option, + from = %entry.origin, + overrides = %previous.origin, + "{message}" + ), + Overlay::ArgumentOverFile => info!( + option, + value = %entry.shown(), + overrides = %previous.shown(), + from = %previous.origin, + "{message}" + ), + }; + + if !entry.value.is_null() { + for other in conflicting(&name) { + let earlier = layer + .get(other) + .is_some_and(|previous| previous.origin.provenance != entry.origin.provenance); + if earlier { + if let Some(previous) = layer.remove(other) { + log( + &format!( + "{name} can't be combined with {other}; dropped the earlier value" + ), + other, + &previous, + ); + } + } + } + } + if let Some(previous) = layer.get(&name) { + let message = match how { + Overlay::IncludeOverInclude => { + "Two included configuration files set the same option; the later one wins" + } + Overlay::FileOverFile => "Configuration file overrides an earlier one", + Overlay::ArgumentOverFile => "Command-line argument overrides the configuration file", + }; + log(message, &name, previous); + } + layer.insert(name, entry); +} + /// The value of a text, by the option's kind. fn from_text(kind: Kind, text: &str) -> Result<JsonValue> { Ok(match kind { @@ -426,6 +543,9 @@ fn resolve_file_value( /// The first option whose value alone doesn't fit (serde doesn't name it). fn culprit(merged: &Layer) -> Option<(&String, &Entry)> { merged.iter().find(|(name, entry)| { + if entry.value.is_null() { + return false; + } let mut alone = serde_json::Map::new(); alone.insert("htpasswd".to_owned(), JsonValue::String(String::new())); alone.insert((*name).clone(), entry.value.clone()); @@ -437,6 +557,7 @@ fn culprit(merged: &Layer) -> Option<(&String, &Entry)> { const CONFIG: &str = "config"; const PRINT_CONFIG: &str = "print-config"; +const UNSET: &str = "unset"; fn command() -> Command { let mut command = Command::new("continuum") @@ -456,6 +577,13 @@ fn command() -> Command { .help("Configuration file (.toml or .json); repeatable, later files win."), ) .arg( + Arg::new(UNSET) + .long(UNSET) + .value_name("OPTION") + .action(ArgAction::Append) + .help("Unset an option set by a configuration file (back to its default); repeatable."), + ) + .arg( Arg::new(PRINT_CONFIG) .long(PRINT_CONFIG) .action(ArgAction::SetTrue) @@ -479,9 +607,15 @@ fn command() -> Command { command } -/// The values given as arguments. +/// The values given as arguments (unsets first, so a value given too wins). fn arguments(matches: &ArgMatches) -> Result<Layer> { let mut layer = Layer::new(); + for name in matches.get_many::<String>(UNSET).into_iter().flatten() { + if spec(name).is_none() { + bail!("--unset {name}: no such option"); + } + layer.insert(name.clone(), unset_entry(Provenance::Argument)); + } for spec in OPTIONS { if matches.value_source(spec.name) != Some(ValueSource::CommandLine) { continue; @@ -522,6 +656,7 @@ fn arguments(matches: &ArgMatches) -> Result<Layer> { value, origin: Origin { provenance: Provenance::Argument, + unset: false, imports, }, }, @@ -571,6 +706,9 @@ impl Loaded { if origin.is_imported() { entry.insert("imported".into(), origin.imports.clone().into()); } + if origin.unset { + entry.insert("unset".into(), true.into()); + } } } let imported = self.origins.get(spec.name).is_some_and(Origin::is_imported); @@ -609,28 +747,11 @@ pub(crate) fn load() -> Result<Loaded> { let mut merged = Layer::new(); for path in &files { for (name, entry) in file::load(path)? { - if let Some(previous) = merged.get(&name) { - debug!( - option = %name, - from = %entry.origin, - overrides = %previous.origin, - "Later configuration file overrides an earlier one" - ); - } - merged.insert(name, entry); + overlay(&mut merged, name, entry, Overlay::FileOverFile); } } for (name, entry) in arguments(&matches)? { - if let Some(previous) = merged.get(&name) { - info!( - option = %name, - value = %entry.shown(), - overrides = %previous.shown(), - from = %previous.origin, - "Command-line argument overrides the configuration file" - ); - } - merged.insert(name, entry); + overlay(&mut merged, name, entry, Overlay::ArgumentOverFile); } let mut loaded = resolve(merged, files)?; loaded.print_only = print_only; @@ -638,8 +759,10 @@ pub(crate) fn load() -> Result<Loaded> { } fn resolve(merged: Layer, files: Vec<PathBuf>) -> Result<Loaded> { + // Unset options take their defaults. let object: serde_json::Map<String, JsonValue> = merged .iter() + .filter(|(_, entry)| !entry.value.is_null()) .map(|(name, entry)| (name.clone(), entry.value.clone())) .collect(); let mut settings: Settings = @@ -682,6 +805,7 @@ mod tests { value: value.clone(), origin: Origin { provenance: Provenance::Argument, + unset: false, imports: Vec::new(), }, }, @@ -718,6 +842,73 @@ mod tests { } #[test] + fn later_sources_override_conflicts_and_unset() { + let from = |provenance: Provenance, value: JsonValue| Entry { + value, + origin: Origin { + provenance, + unset: false, + imports: Vec::new(), + }, + }; + let file = |value| from(Provenance::File("/etc/continuum.toml".into()), value); + let argument = |value| from(Provenance::Argument, value); + let url = json!("http://[::1]:8989"); + + let mut merged = Layer::new(); + overlay( + &mut merged, + "htpasswd".into(), + file(json!("u")), + Overlay::FileOverFile, + ); + overlay( + &mut merged, + "mycelium-key-file".into(), + file(json!("k")), + Overlay::FileOverFile, + ); + overlay( + &mut merged, + "alias".into(), + file(json!("a")), + Overlay::FileOverFile, + ); + + // Both in one source: an error. + let mut same = merged.clone(); + overlay( + &mut same, + "mycelium-rest-url".into(), + file(url.clone()), + Overlay::FileOverFile, + ); + assert!(resolve(same, Vec::new()).is_err()); + + // The command line wins over the conflicting file value, and unsets. + overlay( + &mut merged, + "mycelium-rest-url".into(), + argument(url), + Overlay::ArgumentOverFile, + ); + overlay( + &mut merged, + "alias".into(), + unset_entry(Provenance::Argument), + Overlay::ArgumentOverFile, + ); + let loaded = resolve(merged, Vec::new()).unwrap(); + assert!(loaded.settings.mycelium_key_file.is_none()); + assert!(loaded.settings.mycelium_rest_url.is_some()); + assert!(loaded.settings.alias.is_none()); + assert_eq!( + loaded.report()["options"]["alias"], + json!({ "source": "argument", "unset": true, "value": null }) + ); + } + + #[test] fn report_hides_imported_values() { let mut values = layer(json!({ "htpasswd": "users", "alias": "secret-ish" })); values.get_mut("alias").unwrap().origin.imports = vec!["env:HOSTNAME".into()]; |
