use std::env;
#[derive(Debug, thiserror::Error)]
pub enum ExpandEnvError {
#[error("config references ${{{name}}} but environment variable `{name}` is not set")]
Missing {
name: String,
},
}
pub fn expand_env_vars(text: &str) -> Result<String, ExpandEnvError> {
expand_with(text, |name| {
env::var(name).map_err(|_| ExpandEnvError::Missing {
name: name.to_string(),
})
})
}
fn expand_with<F>(text: &str, mut lookup: F) -> Result<String, ExpandEnvError>
where
F: FnMut(&str) -> Result<String, ExpandEnvError>,
{
let mut out = String::with_capacity(text.len());
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' {
if bytes.get(i + 1) == Some(&b'$') && bytes.get(i + 2) == Some(&b'{') {
out.push_str("${");
i += 3;
continue;
}
if bytes.get(i + 1) == Some(&b'{') {
if let Some(close) = text[i + 2..].bytes().position(|b| b == b'}') {
let name = &text[i + 2..i + 2 + close];
if is_valid_name(name) {
out.push_str(&lookup(name)?);
i = i + 2 + close + 1; continue;
}
}
}
}
let ch = text[i..].chars().next().expect("i on a char boundary");
out.push(ch);
i += ch.len_utf8();
}
Ok(out)
}
fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
fn table<'a>(
pairs: &'a [(&'a str, &'a str)],
) -> impl FnMut(&str) -> Result<String, ExpandEnvError> + 'a {
move |name| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_string())
.ok_or_else(|| ExpandEnvError::Missing {
name: name.to_string(),
})
}
}
#[test]
fn no_tokens_roundtrips_byte_identical() {
let dsn = r#"{"type":"pg","url":"postgres://sui@sui-cache-pg:5432/suicache","max_conns":8}"#;
assert_eq!(expand_with(dsn, table(&[])).unwrap(), dsn);
}
#[test]
fn expands_a_dsn_user_and_password() {
let tmpl = "postgres://${U}:${P}@h:5432/db";
let got = expand_with(tmpl, table(&[("U", "sui"), ("P", "s3cr3t")])).unwrap();
assert_eq!(got, "postgres://sui:s3cr3t@h:5432/db");
}
#[test]
fn missing_var_is_a_hard_error_never_empty() {
let err = expand_with("postgres://${U}:${P}@h/db", table(&[("U", "sui")])).unwrap_err();
let ExpandEnvError::Missing { name } = err;
assert_eq!(name, "P");
}
#[test]
fn double_dollar_brace_is_a_literal() {
assert_eq!(
expand_with("$${NOT_A_VAR}", table(&[])).unwrap(),
"${NOT_A_VAR}"
);
}
#[test]
fn bare_dollar_and_invalid_ref_left_untouched() {
let s = "cost is $5 and ${bad name} and ${}";
assert_eq!(expand_with(s, table(&[])).unwrap(), s);
}
#[test]
fn expands_inside_a_tiered_json_l2_url() {
let json = r#"{"type":"tiered","l2":{"type":"pg","url":"postgres://${U}:${P}@sui-cache-pg-rw:5432/suicache","max_conns":8}}"#;
let got = expand_with(json, table(&[("U", "sui"), ("P", "pw")])).unwrap();
assert!(got.contains("postgres://sui:pw@sui-cache-pg-rw:5432/suicache"));
assert!(got.starts_with(r#"{"type":"tiered","l2":{"type":"pg","url":"postgres://"#));
}
#[test]
fn redis_password_only_dsn() {
let got = expand_with("redis://:${RP}@sui-cache-redis:6379", table(&[("RP", "rpw")])).unwrap();
assert_eq!(got, "redis://:rpw@sui-cache-redis:6379");
}
}