//! 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, }, Argv { argv: Vec, dir: Option, }, } #[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, dir: &Path, ) -> Result> { 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 { 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::>>() .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 { 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 { 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()); } }