use std::io::Write;
use std::process::{Command, Stdio};
use crate::gsm::GsmConfig;
pub enum Backend {
Vault,
Gsm(GsmConfig),
Command(CommandBackend),
}
pub struct CommandBackend {
pub kind: String,
pub read: Vec<String>,
pub write: Option<Vec<String>>,
pub strip_trailing_newline: bool,
}
fn subst(tmpl: &[String], name: &str) -> Vec<String> {
tmpl.iter().map(|a| a.replace("{name}", name)).collect()
}
impl CommandBackend {
pub fn access(&self, name: &str) -> Result<String, String> {
let argv = subst(&self.read, name);
let (prog, args) = argv
.split_first()
.ok_or_else(|| "backend `read` command is empty".to_string())?;
let out = Command::new(prog)
.args(args)
.output()
.map_err(|e| format!("`{prog}` not runnable: {e}"))?;
if out.status.success() {
let mut v = String::from_utf8_lossy(&out.stdout).into_owned();
if self.strip_trailing_newline {
if let Some(s) = v.strip_suffix('\n') {
v = s.to_string();
}
}
Ok(v)
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn add(&self, name: &str, value: &str) -> Result<(), String> {
let tmpl = self.write.as_ref().ok_or_else(|| {
format!("backend `{}` is read-only (no `write` command configured)", self.kind)
})?;
let argv = subst(tmpl, name);
let (prog, args) = argv
.split_first()
.ok_or_else(|| "backend `write` command is empty".to_string())?;
let mut child = Command::new(prog)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("`{prog}` not runnable: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(value.as_bytes())
.map_err(|e| format!("writing secret to `{prog}` stdin: {e}"))?;
}
let out = child
.wait_with_output()
.map_err(|e| format!("waiting on `{prog}`: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
}
impl Backend {
pub fn label(&self) -> String {
match self {
Backend::Vault => "local vault".into(),
Backend::Gsm(cfg) => format!("Google Secret Manager ({})", cfg.project),
Backend::Command(c) => c.kind.clone(),
}
}
pub fn is_external(&self) -> bool {
!matches!(self, Backend::Vault)
}
pub fn access(&self, name: &str) -> Result<String, String> {
match self {
Backend::Vault => Err("local vault values are read via the in-process decrypt path".into()),
Backend::Gsm(cfg) => crate::gsm::access(cfg, name)
.map(|v| v.strip_suffix('\n').map(str::to_string).unwrap_or(v)),
Backend::Command(c) => c.access(name),
}
}
pub fn add(&self, name: &str, value: &str) -> Result<(), String> {
match self {
Backend::Vault => Err("local vault values are written via the in-process vault writer".into()),
Backend::Gsm(cfg) => crate::gsm::add(cfg, name, value),
Backend::Command(c) => c.add(name, value),
}
}
}
pub fn from_table(t: &toml::Table) -> Result<Backend, String> {
let kind = t.get("kind").and_then(|k| k.as_str()).unwrap_or("vault");
match kind {
"vault" => Ok(Backend::Vault),
"gsm" => {
let project = t
.get("project")
.and_then(|p| p.as_str())
.ok_or_else(|| "[backend] kind=\"gsm\" needs `project`".to_string())?
.to_string();
Ok(Backend::Gsm(GsmConfig {
project,
account: t
.get("account")
.and_then(|a| a.as_str())
.map(String::from)
.or_else(|| std::env::var("SECRETS_GSM_ACCOUNT").ok()),
impersonate: t
.get("impersonate")
.and_then(|a| a.as_str())
.map(String::from)
.or_else(|| std::env::var("SECRETS_GSM_IMPERSONATE").ok()),
}))
}
"command" => {
let read = string_array(t.get("read"))
.ok_or_else(|| "[backend] kind=\"command\" needs `read = [...]`".to_string())?;
if read.is_empty() {
return Err("[backend] `read` must name a program".into());
}
let write = string_array(t.get("write"));
let strip_trailing_newline = t
.get("strip_trailing_newline")
.and_then(|b| b.as_bool())
.unwrap_or(true);
Ok(Backend::Command(CommandBackend {
kind: t.get("name").and_then(|n| n.as_str()).unwrap_or("command").to_string(),
read,
write,
strip_trailing_newline,
}))
}
other => Err(format!("unknown [backend] kind \"{other}\" (expected vault, gsm, or command)")),
}
}
fn string_array(v: Option<&toml::Value>) -> Option<Vec<String>> {
v.and_then(|a| a.as_array()).map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
}