summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-24 15:38:32 +0200
committertv <tv@krebsco.de>2026-10-24 15:38:32 +0200
commiteb9cc70985a27fe16788a885b854fb514234c43a (patch)
tree6dfc192abea479f4690766c06b1ffa64d7e95f28
parentfeb349ee09aa2151c421840bec521719c6149dcb (diff)
allow unsetting config options
-rw-r--r--src/config/file.rs73
-rw-r--r--src/config/mod.rs237
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()];