pub fn read_token_file(path: &str) -> Result<String, String> {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read secret file {path}: {e}"))?;
Ok(trim_token(&raw).to_string())
}
fn trim_token(s: &str) -> &str {
s.strip_suffix('\n')
.map(|t| t.strip_suffix('\r').unwrap_or(t))
.unwrap_or(s)
}
static PROMPTED: std::sync::Mutex<Option<std::collections::BTreeMap<String, String>>> =
std::sync::Mutex::new(None);
pub fn set_prompted(name: &str, value: String) {
let mut g = PROMPTED.lock().unwrap_or_else(|e| e.into_inner());
g.get_or_insert_with(Default::default)
.insert(name.to_string(), value);
}
pub fn secret_available(name: &str) -> bool {
prompted_of(name).is_some() || std::env::var(name).is_ok()
}
pub fn prompted_of(name: &str) -> Option<String> {
PROMPTED
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(|m| m.get(name).cloned())
}
pub fn resolve(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
if !template.contains("{{") {
return Ok(template.to_string());
}
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find("{{") {
out.push_str(&rest[..open]);
let after = &rest[open + 2..];
let close = after
.find("}}")
.ok_or_else(|| "unterminated secret ref '{{' (want '{{secret:NAME}}')".to_string())?;
let token = after[..close].trim();
out.push_str(&resolve_one(token, env)?);
rest = &after[close + 2..];
}
out.push_str(rest);
Ok(out)
}
fn resolve_one(token: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<String, String> {
if let Some(path) = token.strip_prefix("secret-file:") {
let path = path.trim();
if path.is_empty() {
return Err("empty {{secret-file:}} path".to_string());
}
read_token_file(path)
} else if let Some(name) = token.strip_prefix("secret:") {
let name = name.trim();
if name.is_empty() {
return Err("empty {{secret:}} name".to_string());
}
prompted_of(name)
.or_else(|| env(name))
.ok_or_else(|| format!("{{{{secret:{name}}}}} is not set in the environment"))
} else {
Err(format!(
"unknown interpolation token '{{{{{token}}}}}' (want {{{{secret:NAME}}}} or {{{{secret-file:PATH}}}})"
))
}
}
pub fn has_secret_ref(value: &str) -> bool {
value.contains("{{secret:") || value.contains("{{secret-file:")
}
pub fn refs_resolvable(template: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<(), String> {
resolve(template, env).map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k: &str| {
pairs
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn trims_one_trailing_newline_only() {
assert_eq!(trim_token("tok\n"), "tok");
assert_eq!(trim_token("tok\r\n"), "tok");
assert_eq!(trim_token("tok"), "tok");
assert_eq!(trim_token("a b\n\n"), "a b\n");
assert_eq!(trim_token(" tok \n"), " tok ");
}
#[test]
fn read_token_file_reads_and_trims() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "super-secret").unwrap();
let v = read_token_file(f.path().to_str().unwrap()).unwrap();
assert_eq!(v, "super-secret");
}
#[test]
fn read_token_file_missing_is_error_without_contents() {
let e = read_token_file("/no/such/secret/file").unwrap_err();
assert!(e.contains("cannot read secret file"));
}
#[test]
fn resolve_passthrough_and_env_ref() {
let env = env_of(&[("ANTHROPIC_API_KEY", "k-123")]);
assert_eq!(resolve("plain text", &env).unwrap(), "plain text");
assert_eq!(
resolve("x-api-key: {{secret:ANTHROPIC_API_KEY}}", &env).unwrap(),
"x-api-key: k-123"
);
assert_eq!(
resolve("Bearer {{secret:ANTHROPIC_API_KEY}}!", &env).unwrap(),
"Bearer k-123!"
);
}
#[test]
fn resolve_file_ref_reads_fresh_and_trims() {
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "file-tok").unwrap();
let path = f.path().to_str().unwrap();
let env = env_of(&[]);
let tmpl = format!("Bearer {{{{secret-file:{path}}}}}");
assert_eq!(resolve(&tmpl, &env).unwrap(), "Bearer file-tok");
}
#[test]
fn resolve_missing_env_is_error_and_does_not_leak_value() {
let env = env_of(&[]);
let e = resolve("{{secret:NOPE}}", &env).unwrap_err();
assert!(e.contains("NOPE"));
assert!(e.contains("not set"));
}
#[test]
fn resolve_unknown_token_and_unterminated_are_errors() {
let env = env_of(&[]);
assert!(resolve("{{bogus:x}}", &env).is_err());
assert!(resolve("{{secret:", &env).is_err());
assert!(resolve("{{secret:}}", &env).is_err());
assert!(resolve("{{secret-file:}}", &env).is_err());
}
#[test]
fn has_secret_ref_detects_both_kinds() {
assert!(has_secret_ref("{{secret:X}}"));
assert!(has_secret_ref("Bearer {{secret-file:/p}}"));
assert!(!has_secret_ref("plain value"));
assert!(!has_secret_ref("2023-06-01"));
}
}