use crate::config::secret_registry::{SecretRegistry, SecretViolation};
use secrecy::SecretString;
pub use concepts::env_var::EnvVarConfig;
#[derive(Debug, thiserror::Error)]
pub(crate) enum EnvVarError {
#[error("environment variable not set: `{0}`")]
Missing(String),
#[error(transparent)]
Secret(#[from] SecretViolation),
}
#[derive(Debug, thiserror::Error)]
#[error("environment variables not set: `{0:?}`")]
pub(crate) struct EnvVarsMissing(pub(crate) Vec<String>);
pub(crate) fn interpolate_env_vars_plaintext(
input: &str,
secret_registry: &SecretRegistry,
) -> Result<String, EnvVarError> {
interpolate_env_vars_inner(input, secret_registry)
}
pub(crate) fn interpolate_env_vars_secret(
input: &str,
secret_registry: &SecretRegistry,
) -> Result<SecretString, EnvVarError> {
interpolate_env_vars_inner(input, secret_registry).map(SecretString::from)
}
fn interpolate_env_vars_inner(
input: &str,
secret_registry: &SecretRegistry,
) -> Result<String, EnvVarError> {
interpolate_core(
input,
&|key| {
secret_registry
.public_env_lookup(key)
.map_err(EnvVarError::from)
},
&|key| EnvVarError::Missing(key),
)
}
pub(crate) fn interpolate_path_template(
input: &str,
synthetics: &[(&'static str, Option<String>)],
secret_registry: &SecretRegistry,
) -> Result<String, anyhow::Error> {
let lookup = |key: &str| -> Result<Option<String>, anyhow::Error> {
match synthetics.iter().find(|(name, _)| *name == key) {
Some((_, val)) => Ok(val.clone()),
None => secret_registry
.public_env_lookup(key)
.map_err(anyhow::Error::from),
}
};
let on_missing = |key: String| -> anyhow::Error {
if synthetics.iter().any(|(name, _)| *name == key) {
anyhow::anyhow!("path variable `${{{key}}}` is not available in this context")
} else {
anyhow::anyhow!("environment variable not set: `{key}`")
}
};
interpolate_core(input, &lookup, &on_missing)
}
fn interpolate_core<E>(
input: &str,
lookup: &dyn Fn(&str) -> Result<Option<String>, E>,
on_missing: &dyn Fn(String) -> E,
) -> Result<String, E> {
let mut out = String::new();
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next(); let mut key = String::new();
let mut closed = false;
let mut default_mode: Option<bool> = None;
let mut default_str = String::new();
let mut depth = 0usize;
while let Some(&ch) = chars.peek() {
chars.next();
if default_mode.is_none() {
if ch == '}' {
closed = true;
break;
} else if ch == '-' {
let colon_dash = key.ends_with(':');
if colon_dash {
key.pop();
}
default_mode = Some(colon_dash);
} else {
key.push(ch);
}
} else {
if ch == '{' {
depth += 1;
} else if ch == '}' {
if depth == 0 {
closed = true;
break;
}
depth -= 1;
}
default_str.push(ch);
}
}
if !closed {
out.push_str("${");
out.push_str(&key);
} else {
match default_mode {
None => match lookup(&key)? {
Some(val) => out.push_str(&val),
None => return Err(on_missing(key)),
},
Some(colon_dash) => {
let val = lookup(&key)?;
let use_default =
val.is_none() || (colon_dash && val.as_deref() == Some(""));
if use_default {
out.push_str(&interpolate_core(&default_str, lookup, on_missing)?);
} else {
out.push_str(val.as_deref().unwrap());
}
}
}
}
} else {
out.push(c);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::secret_registry::SecretRegistry;
fn interp(input: &str) -> Result<String, EnvVarError> {
interpolate_env_vars_inner(input, &SecretRegistry::empty())
}
#[test]
fn registered_secret_interpolation_is_rejected() {
let registry = SecretRegistry::from_test_values([(
"OPENAI_API_KEY".to_string(),
SecretString::from("sk-test"),
)]);
let err =
interpolate_env_vars_plaintext("Bearer ${OPENAI_API_KEY}", ®istry).unwrap_err();
assert!(matches!(err, EnvVarError::Secret(_)), "got {err:?}");
}
#[test]
fn no_interpolation() {
assert_eq!(interp("hello world").unwrap(), "hello world");
}
#[test]
fn single_interpolation() {
unsafe { std::env::set_var("TEST_ENV_VAR_1", "value1") };
assert_eq!(interp("${TEST_ENV_VAR_1}").unwrap(), "value1");
}
#[test]
fn interpolation_with_prefix_suffix() {
unsafe { std::env::set_var("TEST_ENV_VAR_2", "middle") };
assert_eq!(
interp("prefix ${TEST_ENV_VAR_2} suffix").unwrap(),
"prefix middle suffix"
);
}
#[test]
fn multiple_interpolations() {
unsafe { std::env::set_var("TEST_ENV_VAR_A", "aaa") };
unsafe { std::env::set_var("TEST_ENV_VAR_B", "bbb") };
assert_eq!(
interp("${TEST_ENV_VAR_A}-${TEST_ENV_VAR_B}").unwrap(),
"aaa-bbb"
);
}
#[test]
fn missing_env_var() {
let result = interp("${NONEXISTENT_TEST_VAR_XYZ}");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("NONEXISTENT_TEST_VAR_XYZ")
);
}
#[test]
fn dollar_without_brace_is_literal() {
assert_eq!(interp("$hello").unwrap(), "$hello");
}
#[test]
fn empty_string() {
assert_eq!(interp("").unwrap(), "");
}
#[test]
fn colon_dash_unset_uses_default() {
assert_eq!(
interp("${NONEXISTENT_COLON_DASH_XYZ:-fallback}").unwrap(),
"fallback"
);
}
#[test]
fn colon_dash_empty_uses_default() {
unsafe { std::env::set_var("TEST_ENV_COLON_DASH_EMPTY", "") };
assert_eq!(
interp("${TEST_ENV_COLON_DASH_EMPTY:-fallback}").unwrap(),
"fallback"
);
}
#[test]
fn colon_dash_set_uses_value() {
unsafe { std::env::set_var("TEST_ENV_COLON_DASH_SET", "actual") };
assert_eq!(
interp("${TEST_ENV_COLON_DASH_SET:-fallback}").unwrap(),
"actual"
);
}
#[test]
fn bare_dash_unset_uses_default() {
assert_eq!(
interp("${NONEXISTENT_BARE_DASH_XYZ-fallback}").unwrap(),
"fallback"
);
}
#[test]
fn bare_dash_empty_keeps_empty() {
unsafe { std::env::set_var("TEST_ENV_BARE_DASH_EMPTY", "") };
assert_eq!(interp("${TEST_ENV_BARE_DASH_EMPTY-fallback}").unwrap(), "");
}
#[test]
fn bare_dash_set_uses_value() {
unsafe { std::env::set_var("TEST_ENV_BARE_DASH_SET", "actual") };
assert_eq!(
interp("${TEST_ENV_BARE_DASH_SET-fallback}").unwrap(),
"actual"
);
}
#[test]
fn colon_dash_default_is_interpolated() {
unsafe { std::env::set_var("TEST_ENV_NESTED_FALLBACK", "nested_val") };
assert_eq!(
interp("${NONEXISTENT_NESTED_XYZ:-${TEST_ENV_NESTED_FALLBACK}}").unwrap(),
"nested_val"
);
}
fn interp_path(
input: &str,
synthetics: &[(&'static str, Option<String>)],
) -> Result<String, anyhow::Error> {
interpolate_path_template(input, synthetics, &SecretRegistry::empty())
}
#[test]
fn path_template_synthetic_resolves() {
let synthetics = [("DATA_DIR", Some("/data".to_string()))];
assert_eq!(
interp_path("${DATA_DIR}/obelisk-sqlite", &synthetics).unwrap(),
"/data/obelisk-sqlite"
);
}
#[test]
fn path_template_synthetic_wins_over_env() {
unsafe { std::env::set_var("TEMP_DIR", "/env-temp") };
let synthetics = [("TEMP_DIR", Some("/synthetic-temp".to_string()))];
assert_eq!(
interp_path("${TEMP_DIR}/x", &synthetics).unwrap(),
"/synthetic-temp/x"
);
}
#[test]
fn path_template_falls_back_to_env() {
unsafe { std::env::set_var("TEST_PATH_ENV_VAR", "/from-env") };
let synthetics = [("DATA_DIR", Some("/data".to_string()))];
assert_eq!(
interp_path("${TEST_PATH_ENV_VAR}/x", &synthetics).unwrap(),
"/from-env/x"
);
}
#[test]
fn path_template_rejects_registered_secret_name() {
let registry = SecretRegistry::from_test_values([(
"PATH_SECRET".to_string(),
SecretString::from("/secret"),
)]);
let err = interpolate_path_template("${PATH_SECRET}/x", &[], ®istry).unwrap_err();
assert!(err.to_string().contains("PATH_SECRET"));
}
#[test]
fn path_template_unknown_var_errors() {
let synthetics = [("DATA_DIR", Some("/data".to_string()))];
let err = interp_path("${NONEXISTENT_PATH_XYZ}/x", &synthetics).unwrap_err();
assert!(err.to_string().contains("NONEXISTENT_PATH_XYZ"));
}
#[test]
fn path_template_unavailable_synthetic_errors_clearly() {
let synthetics = [("SERVER_CONFIG_DIR", None)];
let err = interp_path("${SERVER_CONFIG_DIR}/x", &synthetics).unwrap_err();
assert!(err.to_string().contains("not available"));
assert!(err.to_string().contains("SERVER_CONFIG_DIR"));
}
#[test]
fn path_template_unavailable_synthetic_uses_default() {
let synthetics = [("DATA_DIR", None)];
assert_eq!(
interp_path("${DATA_DIR:-./local}/x", &synthetics).unwrap(),
"./local/x"
);
}
}