summaryrefslogtreecommitdiffstats
path: root/src/config/import.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-24 15:11:37 +0200
committertv <tv@krebsco.de>2026-10-24 15:11:37 +0200
commite5d590f4e927a18fbed334a3d90650015ed75106 (patch)
tree81483f982a9f719ded1d60b2dd3caa97e6a53a3a /src/config/import.rs
parente66c24101a0a93e49c3959dc6094879d51e823e8 (diff)
add configuration files
Diffstat (limited to 'src/config/import.rs')
-rw-r--r--src/config/import.rs289
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());
+ }
+}