diff options
| author | tv <tv@krebsco.de> | 2026-10-24 15:11:37 +0200 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-24 15:11:37 +0200 |
| commit | e5d590f4e927a18fbed334a3d90650015ed75106 (patch) | |
| tree | 81483f982a9f719ded1d60b2dd3caa97e6a53a3a /src/config/import.rs | |
| parent | e66c24101a0a93e49c3959dc6094879d51e823e8 (diff) | |
add configuration files
Diffstat (limited to 'src/config/import.rs')
| -rw-r--r-- | src/config/import.rs | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/src/config/import.rs b/src/config/import.rs new file mode 100644 index 0000000..895204a --- /dev/null +++ b/src/config/import.rs @@ -0,0 +1,289 @@ +//! Importers: values taken from elsewhere instead of written out, so a configuration +//! can say where a value comes from without holding it (secrets, host facts). +//! +//! * `env:NAME`: an environment variable; +//! * `file:PATH`: a file's contents; +//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`). +//! +//! The content is text (trailing line breaks removed), converted like a value written +//! on the command line; with `+json` (`env+json:NAME`, …) it is parsed as JSON instead. +//! +//! On the command line, a value with one of these prefixes is imported; `literal:` +//! passes the rest as is. In configuration files, an importer is a table: +//! `{ env = "NAME" }`, `{ file = "PATH" }`, `{ command = "COMMAND" }` (or an argument +//! list, run without a shell), each with an optional `format = "json"`. Relative paths +//! are relative to the file, and commands run in its directory. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::{Map as JsonMap, Value as JsonValue}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Format { + Text, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Source { + Env(String), + File(PathBuf), + Shell { + command: String, + dir: Option<PathBuf>, + }, + Argv { + argv: Vec<String>, + dir: Option<PathBuf>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Importer { + pub(crate) source: Source, + pub(crate) format: Format, +} + +/// What a command-line value is: imported, or literal. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Raw<'a> { + Import(Importer), + Literal(&'a str), +} + +/// Reads a command-line value. +pub(crate) fn parse_argument(raw: &str) -> Raw<'_> { + if let Some(rest) = raw.strip_prefix("literal:") { + return Raw::Literal(rest); + } + let Some((prefix, rest)) = raw.split_once(':') else { + return Raw::Literal(raw); + }; + let (kind, format) = match prefix.split_once('+') { + None => (prefix, Format::Text), + Some((kind, "json")) => (kind, Format::Json), + Some(_) => return Raw::Literal(raw), + }; + let source = match kind { + "env" => Source::Env(rest.to_owned()), + "file" => Source::File(PathBuf::from(rest)), + "cmd" => Source::Shell { + command: rest.to_owned(), + dir: None, + }, + _ => return Raw::Literal(raw), + }; + Raw::Import(Importer { source, format }) +} + +/// Reads an importer table from a file in `dir`; None if `object` isn't one. +pub(crate) fn parse_table( + object: &JsonMap<String, JsonValue>, + dir: &Path, +) -> Result<Option<Importer>> { + let sources = ["env", "file", "command"] + .into_iter() + .filter(|key| object.contains_key(*key)) + .count(); + if sources == 0 { + return Ok(None); + } + if sources > 1 { + bail!("an importer takes exactly one of env, file and command"); + } + if let Some(unknown) = object + .keys() + .find(|key| !["env", "file", "command", "format"].contains(&key.as_str())) + { + bail!("unknown importer key {unknown:?}"); + } + let format = match object.get("format") { + None => Format::Text, + Some(JsonValue::String(format)) if format == "text" => Format::Text, + Some(JsonValue::String(format)) if format == "json" => Format::Json, + Some(other) => bail!("importer format must be \"text\" or \"json\", not {other}"), + }; + let text = |key: &str| -> Result<String> { + match object.get(key) { + Some(JsonValue::String(value)) => Ok(value.clone()), + _ => Err(anyhow!("importer {key} must be a string")), + } + }; + let source = if object.contains_key("env") { + Source::Env(text("env")?) + } else if object.contains_key("file") { + Source::File(dir.join(text("file")?)) + } else { + match &object["command"] { + JsonValue::String(command) => Source::Shell { + command: command.clone(), + dir: Some(dir.to_owned()), + }, + JsonValue::Array(items) => { + let argv = items + .iter() + .map(|item| item.as_str().map(str::to_owned)) + .collect::<Option<Vec<_>>>() + .filter(|argv| !argv.is_empty()) + .ok_or_else(|| { + anyhow!("importer command must be a string or a non-empty list of strings") + })?; + Source::Argv { + argv, + dir: Some(dir.to_owned()), + } + } + _ => bail!("importer command must be a string or a non-empty list of strings"), + } + }; + Ok(Some(Importer { source, format })) +} + +impl Importer { + /// Shown instead of the value (which may be secret), e.g. `env:HOSTNAME`. + pub(crate) fn describe(&self) -> String { + let format = match self.format { + Format::Text => "", + Format::Json => "+json", + }; + match &self.source { + Source::Env(name) => format!("env{format}:{name}"), + Source::File(path) => format!("file{format}:{}", path.display()), + Source::Shell { command, .. } => format!("cmd{format}:{command}"), + Source::Argv { argv, .. } => format!("cmd{format}:{}", argv.join(" ")), + } + } + + /// The imported content: text, or JSON. + pub(crate) fn fetch(&self) -> Result<Imported> { + let text = match &self.source { + Source::Env(name) => std::env::var(name).with_context(|| { + format!("environment variable {name} is not set (or not UTF-8)") + })?, + Source::File(path) => std::fs::read_to_string(path) + .with_context(|| format!("reading {}", path.display()))?, + Source::Shell { command, dir } => { + let mut process = Command::new("sh"); + process.arg("-c").arg(command); + run(process, dir.as_deref(), command)? + } + Source::Argv { argv, dir } => { + let mut process = Command::new(&argv[0]); + process.args(&argv[1..]); + run(process, dir.as_deref(), &argv.join(" "))? + } + }; + let text = text.trim_end_matches(['\n', '\r']); + Ok(match self.format { + Format::Text => Imported::Text(text.to_owned()), + Format::Json => Imported::Json( + serde_json::from_str(text) + .with_context(|| format!("{} is not valid JSON", self.describe()))?, + ), + }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Imported { + Text(String), + Json(JsonValue), +} + +fn run(mut process: Command, dir: Option<&Path>, shown: &str) -> Result<String> { + if let Some(dir) = dir { + process.current_dir(dir); + } + let output = process + .output() + .with_context(|| format!("running {shown}"))?; + if !output.status.success() { + bail!( + "{shown} failed ({}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + String::from_utf8(output.stdout).with_context(|| format!("output of {shown} is not UTF-8")) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn command_line_values() { + assert_eq!(parse_argument("plain"), Raw::Literal("plain")); + assert_eq!( + parse_argument("tls://host:9651"), + Raw::Literal("tls://host:9651") + ); + assert_eq!(parse_argument("literal:env:X"), Raw::Literal("env:X")); + assert_eq!( + parse_argument("env+json:SEEDS"), + Raw::Import(Importer { + source: Source::Env("SEEDS".into()), + format: Format::Json + }) + ); + assert_eq!( + parse_argument("cmd:hostname -f"), + Raw::Import(Importer { + source: Source::Shell { + command: "hostname -f".into(), + dir: None + }, + format: Format::Text + }) + ); + assert_eq!(parse_argument("env+yaml:X"), Raw::Literal("env+yaml:X")); + } + + #[test] + fn tables() { + let dir = Path::new("/etc/continuum"); + let table = |value: JsonValue| parse_table(value.as_object().unwrap(), dir); + assert_eq!(table(json!({ "x": 1 })).unwrap(), None); + assert_eq!( + table(json!({ "file": "key", "format": "json" })).unwrap(), + Some(Importer { + source: Source::File("/etc/continuum/key".into()), + format: Format::Json + }) + ); + assert!(table(json!({ "env": "A", "file": "b" })).is_err()); + assert!(table(json!({ "env": "A", "extra": 1 })).is_err()); + assert!(table(json!({ "command": [] })).is_err()); + } + + #[test] + fn fetching() { + let echo = Importer { + source: Source::Argv { + argv: vec!["printf".into(), "[1, 2]\\n\\n".into()], + dir: None, + }, + format: Format::Json, + }; + assert_eq!(echo.fetch().unwrap(), Imported::Json(json!([1, 2]))); + let shell = Importer { + source: Source::Shell { + command: "echo hello".into(), + dir: None, + }, + format: Format::Text, + }; + assert_eq!(shell.fetch().unwrap(), Imported::Text("hello".into())); + let failing = Importer { + source: Source::Shell { + command: "exit 3".into(), + dir: None, + }, + format: Format::Text, + }; + assert!(failing.fetch().is_err()); + } +} |
