From 813f9c57d227a686c6fb08137a2b6b26e9986ef4 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 26 Oct 2026 23:21:01 +0100 Subject: show interpolated values in GET /admin/config --- src/config/file.rs | 14 +-- src/config/import.rs | 6 ++ src/config/mod.rs | 295 ++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 260 insertions(+), 55 deletions(-) (limited to 'src/config') diff --git a/src/config/file.rs b/src/config/file.rs index 50b3d48..b30d147 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -16,7 +16,7 @@ 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}; +use super::{Entry, Layer, Overlay, Provenance, overlay, spec, unset_entry}; const INCLUDE: &str = "include"; const INCLUDE_OPTIONAL: &str = "include-optional"; @@ -80,14 +80,14 @@ fn load_inner(path: &Path, loading: &mut Vec) -> Result { 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) + let (value, sources) = 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, - }, + origin: sources.origin( + Provenance::File(canonical.clone()), + value.is_null(), + spec.kind, + ), value, }; overlay(&mut layer, name, entry, Overlay::FileOverFile); diff --git a/src/config/import.rs b/src/config/import.rs index a5b2a63..9f315f3 100644 --- a/src/config/import.rs +++ b/src/config/import.rs @@ -185,6 +185,12 @@ impl Importer { } } + /// Whether this is text with placeholders (`interpolated`) rather than an importer + /// of the whole value: its result is shown, the importers' are not. + pub(crate) fn is_interpolation(&self) -> bool { + matches!(self.source, Source::Template { .. }) + } + /// The imported content: text, or JSON. pub(crate) fn fetch(&self) -> Result { let text = match &self.source { diff --git a/src/config/mod.rs b/src/config/mod.rs index fd8547d..928730e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -51,6 +51,18 @@ pub(crate) struct OptionSpec { pub(crate) default: Option<&'static str>, pub(crate) help: &'static str, pub(crate) hidden: bool, + /// Its value is never shown (logs, `--print-config`, `GET /admin/config`), however + /// it was given. + pub(crate) secret: bool, +} + +impl OptionSpec { + const fn secret(self) -> Self { + Self { + secret: true, + ..self + } + } } const fn option( @@ -67,6 +79,7 @@ const fn option( default, help, hidden: false, + secret: false, } } @@ -214,7 +227,9 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "SCHEME://HOSTNAME:PORT", None, "Use a Mycelium daemon running elsewhere (its REST API) instead of supervising one. Excludes the other mycelium options except mycelium-peers.", - ), + ) + // A URL may carry credentials. + .secret(), option( "mycelium-key-file", Kind::Text, @@ -257,6 +272,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ default: None, help: "Test hook: start on a non-genesis epoch of this generation (with a fixed dummy id). Needs debug-api.", hidden: true, + secret: false, }, ]; @@ -454,8 +470,14 @@ pub(crate) struct Origin { /// 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`. + /// Importers the value (or some list elements) came from, e.g. `env:HOSTNAME`. The + /// value is never shown. pub(crate) imports: Vec, + /// Text with placeholders the value (or some list elements) came from, e.g. + /// `node-${env:HOSTNAME}`. The value is shown. + pub(crate) templates: Vec, + /// With placeholders: the value as written, placeholders and all. + pub(crate) written: Option, } impl Origin { @@ -473,6 +495,7 @@ impl std::fmt::Display for Origin { if self.unset { write!(f, " (unset)")?; } + // Not the text with placeholders: of a secret, it may hold some of it. if self.is_imported() { write!(f, " ({})", self.imports.join(", "))?; } @@ -487,10 +510,12 @@ pub(crate) struct Entry { } impl Entry { - /// The value for logs: imported values are never shown. - pub(crate) fn shown(&self) -> String { + /// The value of `option` for logs: imported values and secrets are never shown. + pub(crate) fn shown(&self, option: &str) -> String { if self.origin.unset { "".to_owned() + } else if spec(option).is_some_and(|spec| spec.secret) { + "".to_owned() } else if self.origin.is_imported() { "".to_owned() } else { @@ -510,6 +535,8 @@ fn unset_entry(provenance: Provenance) -> Entry { provenance, unset: true, imports: Vec::new(), + templates: Vec::new(), + written: None, }, } } @@ -559,9 +586,9 @@ pub(crate) fn overlay(layer: &mut Layer, name: String, entry: Entry, how: Overla let log = |message: &str, option: &str, previous: &Entry| match how { Overlay::IncludeOverInclude => warn!( option, - value = %entry.shown(), + value = %entry.shown(&name), from = %entry.origin, - overrides = %previous.shown(), + overrides = %previous.shown(option), overridden_from = %previous.origin, "{message}" ), @@ -573,8 +600,8 @@ pub(crate) fn overlay(layer: &mut Layer, name: String, entry: Entry, how: Overla ), Overlay::ArgumentOverFile => info!( option, - value = %entry.shown(), - overrides = %previous.shown(), + value = %entry.shown(&name), + overrides = %previous.shown(option), from = %previous.origin, "{message}" ), @@ -649,28 +676,73 @@ fn from_import(kind: Kind, importer: &import::Importer) -> Result { } /// A value from a file in `dir`: importer tables are resolved, also as list elements. +/// Collects where the parts of a value came from, for its [`Origin`]. +#[derive(Default)] +struct Sources { + imports: Vec, + templates: Vec, + /// The parts as written: literal values, importers, text with placeholders. + written: Vec, +} + +impl Sources { + /// A literal part (list values count element by element). + fn literal(&mut self, value: &JsonValue) { + match value { + JsonValue::Array(items) => self.written.extend(items.iter().cloned()), + other => self.written.push(other.clone()), + } + } + + /// A part from `importer`. + fn import(&mut self, importer: &import::Importer) { + let described = importer.describe(); + if importer.is_interpolation() { + self.templates.push(described.clone()); + } else { + self.imports.push(described.clone()); + } + self.written.push(JsonValue::String(described)); + } + + fn origin(self, provenance: Provenance, unset: bool, kind: Kind) -> Origin { + let written = (!self.templates.is_empty()).then(|| match kind { + Kind::List => JsonValue::Array(self.written), + _ => self.written.into_iter().last().unwrap_or(JsonValue::Null), + }); + Origin { + provenance, + unset, + imports: self.imports, + templates: self.templates, + written, + } + } +} + +/// A value from a file, and where its parts came from. fn resolve_file_value( spec: &OptionSpec, value: JsonValue, dir: &Path, -) -> Result<(JsonValue, Vec)> { - match value { +) -> Result<(JsonValue, Sources)> { + let mut sources = Sources::default(); + let value = match value { JsonValue::Object(table) => match import::parse_table(&table, dir)? { - Some(importer) => Ok(( - from_import(spec.kind, &importer)?, - vec![importer.describe()], - )), + Some(importer) => { + sources.import(&importer); + from_import(spec.kind, &importer)? + } None => bail!("a table here must be an importer (env, file or command)"), }, JsonValue::String(text) => match import::interpolated(&text, Some(dir)) { - Some(importer) => Ok(( - from_import(spec.kind, &importer)?, - vec![importer.describe()], - )), - None => Ok((JsonValue::String(text), Vec::new())), + Some(importer) => { + sources.import(&importer); + from_import(spec.kind, &importer)? + } + None => JsonValue::String(text), }, JsonValue::Array(items) if spec.kind == Kind::List => { - let mut imports = Vec::new(); let mut values = Vec::new(); for item in items { match item { @@ -682,22 +754,30 @@ fn resolve_file_value( JsonValue::Array(more) => values.extend(more), other => values.push(other), } - imports.push(importer.describe()); + sources.import(&importer); } JsonValue::String(text) => match import::interpolated(&text, Some(dir)) { Some(importer) => { values.push(from_import(Kind::Text, &importer)?); - imports.push(importer.describe()); + sources.import(&importer); + } + None => { + let value = JsonValue::String(text); + sources.literal(&value); + values.push(value); } - None => values.push(JsonValue::String(text)), }, - other => values.push(other), + other => { + sources.literal(&other); + values.push(other); + } } } - Ok((JsonValue::Array(values), imports)) + JsonValue::Array(values) } - other => Ok((other, Vec::new())), - } + other => other, + }; + Ok((value, sources)) } /// The first option whose value alone doesn't fit (serde doesn't name it). @@ -782,16 +862,18 @@ fn arguments(matches: &ArgMatches) -> Result { if matches.value_source(spec.name) != Some(ValueSource::CommandLine) { continue; } - let mut imports = Vec::new(); + let mut sources = Sources::default(); let value = if spec.kind == Kind::Flag { JsonValue::Bool(true) } else { let mut values = Vec::new(); for raw in matches.get_many::(spec.name).into_iter().flatten() { let value = match import::parse_argument(raw) { - Raw::Literal(text) => from_text(spec.kind, text), + Raw::Literal(text) => from_text(spec.kind, text).inspect(|value| { + sources.literal(value); + }), Raw::Import(importer) => { - imports.push(importer.describe()); + sources.import(&importer); from_import(spec.kind, &importer) } } @@ -816,11 +898,7 @@ fn arguments(matches: &ArgMatches) -> Result { spec.name.to_owned(), Entry { value, - origin: Origin { - provenance: Provenance::Argument, - unset: false, - imports, - }, + origin: sources.origin(Provenance::Argument, false, spec.kind), }, ); } @@ -837,13 +915,41 @@ pub(crate) struct Loaded { pub(crate) print_only: bool, } +/// How `GET /admin/config` shows text with placeholders (`?values=`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum Values { + /// As it ends up. + #[default] + Interpolated, + /// As written, placeholders and all. + Original, +} + +/// The configuration reports, made once at start for `GET /admin/config`. +#[derive(Debug, Clone, Default)] +pub(crate) struct Reports { + interpolated: JsonValue, + original: JsonValue, +} + +impl Reports { + pub(crate) fn get(&self, values: Values) -> &JsonValue { + match values { + Values::Interpolated => &self.interpolated, + Values::Original => &self.original, + } + } +} + impl Loaded { /// The effective configuration with each value's source, for `--print-config` and /// `GET /admin/config`. Imported values are left out (they may be secret); their - /// importers are listed instead. - pub(crate) fn report(&self) -> JsonValue { - let values = match serde_json::to_value(&self.settings) { - Ok(JsonValue::Object(values)) => values, + /// importers are listed instead. Secrets are left out however they were given. + /// Text with placeholders is shown as `values` says, the text as written listed. + pub(crate) fn report(&self, values: Values) -> JsonValue { + let settings = match serde_json::to_value(&self.settings) { + Ok(JsonValue::Object(settings)) => settings, _ => serde_json::Map::new(), }; let options: serde_json::Map = OPTIONS @@ -851,7 +957,8 @@ impl Loaded { .filter(|spec| !spec.hidden || self.origins.contains_key(spec.name)) .map(|spec| { let mut entry = serde_json::Map::new(); - match self.origins.get(spec.name) { + let origin = self.origins.get(spec.name); + match origin { None => { entry.insert("source".into(), "default".into()); } @@ -868,17 +975,25 @@ impl Loaded { if origin.is_imported() { entry.insert("imported".into(), origin.imports.clone().into()); } + // Of a secret, the text may hold some of it. + if !origin.templates.is_empty() && !spec.secret { + entry.insert("templates".into(), origin.templates.clone().into()); + } if origin.unset { entry.insert("unset".into(), true.into()); } } } - let imported = self.origins.get(spec.name).is_some_and(Origin::is_imported); - if !imported { - entry.insert( - "value".into(), - values.get(spec.name).cloned().unwrap_or(JsonValue::Null), - ); + let given = origin.is_some_and(|origin| !origin.unset); + if spec.secret && given { + entry.insert("secret".into(), true.into()); + } else if !origin.is_some_and(Origin::is_imported) { + let written = origin.and_then(|origin| origin.written.clone()); + let value = match (values, written) { + (Values::Original, Some(written)) => written, + _ => settings.get(spec.name).cloned().unwrap_or(JsonValue::Null), + }; + entry.insert("value".into(), value); } (spec.name.to_owned(), JsonValue::Object(entry)) }) @@ -888,6 +1003,13 @@ impl Loaded { "options": options, }) } + + pub(crate) fn reports(&self) -> Reports { + Reports { + interpolated: self.report(Values::Interpolated), + original: self.report(Values::Original), + } + } } /// Reads the command line and the configuration files it names. Exits on `--help`, @@ -979,6 +1101,8 @@ mod tests { provenance: Provenance::Argument, unset: false, imports: Vec::new(), + templates: Vec::new(), + written: None, }, }, ) @@ -994,6 +1118,8 @@ mod tests { provenance, unset: false, imports: Vec::new(), + templates: Vec::new(), + written: None, }, }; let mut merged = layer(json!({ "htpasswd": "u" })); @@ -1041,6 +1167,8 @@ mod tests { provenance, unset: false, imports: Vec::new(), + templates: Vec::new(), + written: None, }, }; let file = |value| from(Provenance::File("/etc/continuum.toml".into()), value); @@ -1095,7 +1223,7 @@ mod tests { assert!(loaded.settings.mycelium_rest_url.is_some()); assert!(loaded.settings.alias.is_none()); assert_eq!( - loaded.report()["options"]["alias"], + loaded.report(Values::Interpolated)["options"]["alias"], json!({ "source": "argument", "unset": true, "value": null }) ); } @@ -1104,7 +1232,9 @@ mod tests { 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()]; - let report = resolve(values, Vec::new()).unwrap().report(); + let report = resolve(values, Vec::new()) + .unwrap() + .report(Values::Interpolated); let options = &report["options"]; assert_eq!( options["htpasswd"], @@ -1212,4 +1342,73 @@ mod tests { assert_eq!(layer["debug-api"].value, json!(true)); assert!(!layer.contains_key("neighbours")); } + + #[test] + fn report_shows_interpolated_values() { + // SAFETY: tests in this module don't read this variable concurrently. + unsafe { std::env::set_var("CONTINUUM_TEST_SITE", "berlin") }; + let matches = command() + .try_get_matches_from([ + "continuum", + "--htpasswd", + "users", + "--alias", + "node-${env:CONTINUUM_TEST_SITE}", + "--seeds", + "a,b", + "--seeds", + "c-${env:CONTINUUM_TEST_SITE}", + ]) + .unwrap(); + let layer = arguments(&matches).unwrap(); + assert!(!layer["alias"].origin.is_imported()); + assert_eq!(layer["alias"].shown("alias"), "\"node-berlin\""); + let loaded = resolve(layer, Vec::new()).unwrap(); + + let report = loaded.report(Values::Interpolated); + assert_eq!( + report["options"]["alias"], + json!({ + "source": "argument", + "templates": ["node-${env:CONTINUUM_TEST_SITE}"], + "value": "node-berlin", + }) + ); + assert_eq!( + report["options"]["seeds"]["value"], + json!(["a", "b", "c-berlin"]) + ); + + let report = loaded.report(Values::Original); + assert_eq!( + report["options"]["alias"]["value"], + json!("node-${env:CONTINUUM_TEST_SITE}") + ); + assert_eq!( + report["options"]["seeds"]["value"], + json!(["a", "b", "c-${env:CONTINUUM_TEST_SITE}"]) + ); + // Without placeholders, both are the same. + assert_eq!( + report["options"]["neighbours"], + json!({ "source": "default", "value": 12 }) + ); + } + + #[test] + fn secrets_are_never_shown() { + let values = + layer(json!({ "htpasswd": "users", "mycelium-rest-url": "http://u:pw@h:8989" })); + assert_eq!( + values["mycelium-rest-url"].shown("mycelium-rest-url"), + "" + ); + let loaded = resolve(values, Vec::new()).unwrap(); + for mode in [Values::Interpolated, Values::Original] { + assert_eq!( + loaded.report(mode)["options"]["mycelium-rest-url"], + json!({ "source": "argument", "secret": true }) + ); + } + } } -- cgit v1.3.1