use std::collections::BTreeSet;
use crate::errors::OrionError;
const OWN_SEMANTICS: &[&str] = &["vars", "secrets"];
pub(super) fn resolve_env_references(
doc: &mut toml::Value,
referenced: &mut BTreeSet<String>,
) -> Result<(), OrionError> {
let toml::Value::Table(root) = doc else {
return Ok(());
};
for (section, value) in root.iter_mut() {
if OWN_SEMANTICS.contains(§ion.as_str()) {
continue;
}
walk(value, section, referenced)?;
}
Ok(())
}
fn walk(
value: &mut toml::Value,
path: &str,
referenced: &mut BTreeSet<String>,
) -> Result<(), OrionError> {
match value {
toml::Value::String(s) => {
if let Some(resolved) = resolve_one(s, path, referenced)? {
*s = resolved;
}
}
toml::Value::Table(t) => {
for (k, v) in t.iter_mut() {
walk(v, &format!("{path}.{k}"), referenced)?;
}
}
toml::Value::Array(items) => {
for (i, v) in items.iter_mut().enumerate() {
walk(v, &format!("{path}[{i}]"), referenced)?;
}
}
_ => {}
}
Ok(())
}
fn resolve_one(
s: &str,
path: &str,
referenced: &mut BTreeSet<String>,
) -> Result<Option<String>, OrionError> {
let Some(name) = s.strip_prefix("env://") else {
if let Some(scheme) = crate::connector::secrets::RESERVED_SCHEMES
.iter()
.find(|scheme| s.starts_with(&format!("{scheme}://")))
{
return Err(OrionError::Config {
message: format!(
"{path} uses '{scheme}://', which the config file cannot resolve — \
it is read before the process has a runtime to reach a secret \
backend with. Use 'env://NAME' here, or declare the value under \
[secrets], which resolves at startup through every scheme."
),
});
}
return Ok(None);
};
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(OrionError::Config {
message: format!("Invalid env-var name '{name}' in {path} (allowed: [A-Za-z0-9_])"),
});
}
referenced.insert(name.to_string());
std::env::var(name)
.map(Some)
.map_err(|_| OrionError::Config {
message: format!(
"Required environment variable '{name}' is not set (referenced as \
'env://{name}' by {path})."
),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn resolve(input: &str) -> Result<toml::Value, OrionError> {
let mut doc: toml::Value = toml::from_str(input).expect("valid toml");
let mut referenced = BTreeSet::new();
resolve_env_references(&mut doc, &mut referenced)?;
Ok(doc)
}
#[test]
fn a_reference_resolves_wherever_it_sits() {
unsafe { std::env::set_var("ORION_TEST_REF_URL", "postgres://u:p@h/db") };
let doc = resolve("[storage]\nurl = \"env://ORION_TEST_REF_URL\"\n").expect("resolves");
assert_eq!(doc["storage"]["url"].as_str(), Some("postgres://u:p@h/db"));
}
#[test]
fn an_unset_variable_is_named() {
let err =
resolve("[storage]\nurl = \"env://ORION_TEST_REF_ABSENT\"\n").expect_err("must fail");
let message = err.to_string();
assert!(message.contains("ORION_TEST_REF_ABSENT"), "{message}");
assert!(message.contains("storage.url"), "{message}");
}
#[test]
fn vars_and_secrets_are_left_alone() {
let doc =
resolve("[vars]\na = \"env://ORION_TEST_REF_ABSENT\"\n[secrets]\nb = \"vault://x\"\n")
.expect("neither section is walked");
assert_eq!(
doc["vars"]["a"].as_str(),
Some("env://ORION_TEST_REF_ABSENT")
);
assert_eq!(doc["secrets"]["b"].as_str(), Some("vault://x"));
}
#[test]
fn a_reserved_scheme_is_refused_with_the_reason() {
let err = resolve("[storage]\nurl = \"vault://secret/db\"\n").expect_err("must fail");
let message = err.to_string();
assert!(message.contains("vault://"), "{message}");
assert!(message.contains("[secrets]"), "{message}");
}
#[test]
fn an_ordinary_string_is_untouched() {
let doc = resolve("[storage]\nurl = \"sqlite:orion.db\"\n").expect("resolves");
assert_eq!(doc["storage"]["url"].as_str(), Some("sqlite:orion.db"));
}
}