use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
pub fn load_vars(paths: &[std::path::PathBuf]) -> Result<BTreeMap<String, String>> {
let mut vars = BTreeMap::new();
for path in paths {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let value: toml::Value =
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?;
flatten(&value, String::new(), &mut vars);
}
vars.insert("sennit.os".into(), crate::packages::current_os().into());
vars.insert("sennit.hostname".into(), hostname());
vars.insert(
"sennit.profile".into(),
crate::packages::current_profiles().join(","),
);
for (k, v) in std::env::vars() {
vars.insert(format!("env.{k}"), v);
}
Ok(vars)
}
fn hostname() -> String {
std::process::Command::new("hostname")
.arg("-s")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".into())
}
fn flatten(value: &toml::Value, prefix: String, out: &mut BTreeMap<String, String>) {
match value {
toml::Value::Table(t) => {
for (k, v) in t {
let key = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
flatten(v, key, out);
}
}
toml::Value::String(s) => {
out.insert(prefix, s.clone());
}
other => {
out.insert(prefix, other.to_string());
}
}
}
pub fn split_reference(key: &str) -> Option<(&str, &str)> {
let (scheme, rest) = key.split_once("://")?;
if scheme.is_empty() || rest.is_empty() || scheme.contains(char::is_whitespace) {
return None;
}
Some((scheme, rest))
}
pub fn needs_secrets(template: &str) -> bool {
let mut rest = template;
while let Some(i) = rest.find("{{") {
let after = &rest[i + 2..];
let Some(end) = after.find("}}") else {
return false;
};
if split_reference(after[..end].trim()).is_some() {
return true;
}
rest = &after[end..];
}
false
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Provider {
pub command: String,
#[serde(default = "yes")]
pub trim: bool,
}
fn yes() -> bool {
true
}
pub type Providers = BTreeMap<String, Provider>;
pub fn default_providers() -> Providers {
let mut m = BTreeMap::new();
m.insert(
"op".to_string(),
Provider {
command: "op read --no-newline {}".into(),
trim: true,
},
);
m
}
#[derive(Default)]
pub struct SecretCache {
seen: BTreeMap<String, String>,
providers: Providers,
}
impl SecretCache {
pub fn with(providers: Providers) -> Self {
Self {
seen: BTreeMap::new(),
providers,
}
}
fn read(&mut self, scheme: &str, reference: &str) -> Result<String> {
let key = format!("{scheme}://{reference}");
if let Some(v) = self.seen.get(&key) {
return Ok(v.clone());
}
let Some(provider) = self.providers.get(scheme) else {
let known: Vec<&str> = self.providers.keys().map(String::as_str).collect();
bail!(
"no provider declared for `{scheme}://`. Known: {}",
if known.is_empty() {
"(none)".to_string()
} else {
known.join(", ")
}
);
};
let mut parts = shell_words(&provider.command);
if parts.is_empty() {
bail!("provider `{scheme}` has an empty command");
}
for part in parts.iter_mut() {
*part = part.replace("{}", reference);
}
let bin = parts.remove(0);
let out = std::process::Command::new(&bin)
.args(&parts)
.output()
.with_context(|| format!("failed to run `{bin}` for {scheme}://; is it installed?"))?;
if !out.status.success() {
bail!(
"`{} {}` failed: {}",
bin,
parts.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
let mut value =
String::from_utf8(out.stdout).with_context(|| format!("{key} is not valid UTF-8"))?;
if provider.trim {
while value.ends_with('\n') || value.ends_with('\r') {
value.pop();
}
}
self.seen.insert(key, value.clone());
Ok(value)
}
}
fn shell_words(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut had_quote = false;
for c in s.chars() {
match (quote, c) {
(Some(q), ch) if ch == q => quote = None,
(Some(_), ch) => cur.push(ch),
(None, '\'') | (None, '"') => {
quote = Some(c);
had_quote = true;
}
(None, ch) if ch.is_whitespace() => {
if !cur.is_empty() || had_quote {
out.push(std::mem::take(&mut cur));
had_quote = false;
}
}
(None, ch) => cur.push(ch),
}
}
if !cur.is_empty() || had_quote {
out.push(cur);
}
out
}
fn strip_conditionals(
template: &str,
vars: &BTreeMap<String, String>,
source: &str,
) -> Result<String> {
let mut out = String::with_capacity(template.len());
let mut rest = template;
let mut stack: Vec<bool> = Vec::new();
while let Some(i) = rest.find("{{") {
let Some(end_rel) = rest[i + 2..].find("}}") else {
break;
};
let directive = rest[i + 2..i + 2 + end_rel].trim();
let before = &rest[..i];
let after = &rest[i + 2 + end_rel + 2..];
let keeping = stack.iter().all(|k| *k);
if keeping {
out.push_str(before);
}
if let Some(cond) = directive.strip_prefix("if ") {
stack.push(evaluate(cond.trim(), vars, source)?);
trim_line(&out, after, &mut rest);
continue;
}
if directive == "else" {
let Some(top) = stack.pop() else {
bail!("{source}: `else` without `if`");
};
stack.push(!top);
trim_line(&out, after, &mut rest);
continue;
}
if directive == "end" {
if stack.pop().is_none() {
bail!("{source}: `end` without `if`");
}
trim_line(&out, after, &mut rest);
continue;
}
if keeping {
out.push_str(&rest[i..i + 2 + end_rel + 2]);
}
rest = after;
}
if !stack.is_empty() {
bail!("{source}: unterminated `if`");
}
if stack.iter().all(|k| *k) {
out.push_str(rest);
}
Ok(out)
}
fn trim_line<'a>(out: &str, after: &'a str, rest: &mut &'a str) {
if out.ends_with('\n') || out.is_empty() {
*rest = after.strip_prefix('\n').unwrap_or(after);
} else {
*rest = after;
}
}
fn evaluate(cond: &str, vars: &BTreeMap<String, String>, source: &str) -> Result<bool> {
for (op, negate) in [("==", false), ("!=", true)] {
if let Some((l, r)) = cond.split_once(op) {
let l = resolve(l.trim(), vars, source)?;
let r = resolve(r.trim(), vars, source)?;
return Ok((l == r) != negate);
}
}
Ok(!resolve(cond, vars, source)?.is_empty())
}
fn resolve(token: &str, vars: &BTreeMap<String, String>, source: &str) -> Result<String> {
if (token.starts_with('"') && token.ends_with('"') && token.len() >= 2)
|| (token.starts_with('\'') && token.ends_with('\'') && token.len() >= 2)
{
return Ok(token[1..token.len() - 1].to_string());
}
match vars.get(token) {
Some(v) => Ok(v.clone()),
None => bail!("{source}: unknown variable `{token}` in a condition"),
}
}
pub fn expand_with(
template: &str,
vars: &BTreeMap<String, String>,
source: &str,
secrets: &mut SecretCache,
) -> Result<String> {
let template = strip_conditionals(template, vars, source)?;
let template = template.as_str();
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("{{") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let Some(end) = after.find("}}") else {
bail!("{source}: unterminated `{{{{`");
};
let key = after[..end].trim();
if let Some((scheme, reference)) = split_reference(key) {
out.push_str(&secrets.read(scheme, reference)?);
} else {
match vars.get(key) {
Some(v) => out.push_str(v),
None => bail!("{source}: unknown template variable `{key}`"),
}
}
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn expand_with_test(
template: &str,
vars: &BTreeMap<String, String>,
source: &str,
) -> Result<String> {
expand_with(template, vars, source, &mut SecretCache::default())
}
fn vars() -> BTreeMap<String, String> {
let mut v = BTreeMap::new();
v.insert("ui.bg".into(), "#1a1b26".into());
v.insert("normal.red".into(), "#f7768e".into());
v
}
#[test]
fn expands_known_variables() {
let out = expand_with_test("bg = \"{{ ui.bg }}\"", &vars(), "t").unwrap();
assert_eq!(out, "bg = \"#1a1b26\"");
}
#[test]
fn expands_multiple_occurrences() {
let out =
expand_with_test("{{ ui.bg }}/{{ normal.red }}/{{ ui.bg }}", &vars(), "t").unwrap();
assert_eq!(out, "#1a1b26/#f7768e/#1a1b26");
}
#[test]
fn leaves_text_without_placeholders_untouched() {
let src = "no placeholders here";
assert_eq!(expand_with_test(src, &vars(), "t").unwrap(), src);
}
#[test]
fn tolerates_whitespace_in_placeholder() {
assert_eq!(
expand_with_test("{{ui.bg}}", &vars(), "t").unwrap(),
"#1a1b26"
);
assert_eq!(
expand_with_test("{{ ui.bg }}", &vars(), "t").unwrap(),
"#1a1b26"
);
}
#[test]
fn unknown_variable_is_an_error() {
let err = expand_with_test("{{ nope }}", &vars(), "t.tmpl").unwrap_err();
assert!(err.to_string().contains("unknown template variable"));
assert!(err.to_string().contains("t.tmpl"));
}
#[test]
fn unterminated_placeholder_is_an_error() {
assert!(expand_with_test("{{ ui.bg", &vars(), "t").is_err());
}
fn cond(t: &str) -> Result<String> {
let mut v = vars();
v.insert("sennit.os".into(), "darwin".into());
v.insert("sennit.profile".into(), String::new());
strip_conditionals(t, &v, "t")
}
#[test]
fn keeps_the_true_branch() {
let out = cond("{{ if sennit.os == \"darwin\" }}\nmac\n{{ end }}\n").unwrap();
assert_eq!(out, "mac\n");
}
#[test]
fn drops_the_false_branch() {
let out = cond("{{ if sennit.os == \"linux\" }}\nlinux\n{{ end }}\n").unwrap();
assert_eq!(out, "");
}
#[test]
fn handles_else() {
let out = cond("{{ if sennit.os == \"linux\" }}\na\n{{ else }}\nb\n{{ end }}\n").unwrap();
assert_eq!(out, "b\n");
}
#[test]
fn not_equal_works() {
let out = cond("{{ if sennit.os != \"linux\" }}\nmac\n{{ end }}\n").unwrap();
assert_eq!(out, "mac\n");
}
#[test]
fn a_bare_variable_is_true_when_not_empty() {
assert_eq!(cond("{{ if sennit.os }}\nx\n{{ end }}\n").unwrap(), "x\n");
assert_eq!(cond("{{ if sennit.profile }}\nx\n{{ end }}\n").unwrap(), "");
}
#[test]
fn literal_text_resembling_directives_is_untouched() {
let out = cond("[end]\nname = 1\n").unwrap();
assert_eq!(out, "[end]\nname = 1\n");
}
#[test]
fn nesting_works() {
let out =
cond("{{ if sennit.os == \"darwin\" }}\n{{ if sennit.os }}\ny\n{{ end }}\n{{ end }}\n")
.unwrap();
assert_eq!(out, "y\n");
}
#[test]
fn unbalanced_blocks_are_errors() {
assert!(cond("{{ if sennit.os }}\nx\n").is_err());
assert!(cond("x\n{{ end }}\n").is_err());
assert!(cond("{{ else }}\n").is_err());
}
#[test]
fn unknown_variable_in_a_condition_is_an_error() {
assert!(cond("{{ if nope == \"x\" }}\ny\n{{ end }}\n").is_err());
}
#[test]
fn detects_secret_references() {
assert!(needs_secrets("token = {{ op://Vault/Item/field }}"));
assert!(!needs_secrets("bg = {{ ui.bg }}"));
assert!(!needs_secrets("no placeholders"));
}
#[test]
fn unterminated_placeholder_is_not_a_secret() {
assert!(!needs_secrets("{{ op://Vault"));
}
#[test]
fn flattens_nested_tables() {
let toml_src = "[ui]\nbg = \"#111\"\n\n[normal]\nred = \"#f00\"\n";
let value: toml::Value = toml::from_str(toml_src).unwrap();
let mut out = BTreeMap::new();
flatten(&value, String::new(), &mut out);
assert_eq!(out.get("ui.bg").unwrap(), "#111");
assert_eq!(out.get("normal.red").unwrap(), "#f00");
}
}