use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::OnceLock;
pub(crate) static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub(crate) fn init(settings: &Settings) {
SETTINGS
.set(settings.clone())
.expect("umbral::settings::init called more than once");
}
pub fn get() -> &'static Settings {
SETTINGS
.get()
.expect("umbral: settings not initialised — did you call App::build()?")
}
pub fn get_opt() -> Option<&'static Settings> {
SETTINGS.get()
}
fn default_database_url() -> String {
"sqlite::memory:".into()
}
fn default_max_form_body_bytes() -> Option<usize> {
Some(16 * 1024 * 1024)
}
fn default_secret_key() -> String {
"umbral-insecure-dev-key-change-me".into()
}
fn default_allowed_hosts() -> Vec<String> {
vec!["localhost".into(), "127.0.0.1".into()]
}
fn deserialize_string_list<'de, D>(de: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match OneOrMany::deserialize(de)? {
OneOrMany::One(s) => s
.split(',')
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_string)
.collect(),
OneOrMany::Many(v) => v,
})
}
fn default_log_level() -> String {
"info".into()
}
fn default_db_max_connections() -> u32 {
10
}
fn default_db_acquire_timeout_secs() -> u64 {
30
}
fn default_db_min_connections() -> u32 {
0
}
fn default_db_idle_timeout_secs() -> Option<u64> {
Some(600)
}
fn default_db_max_lifetime_secs() -> Option<u64> {
Some(1800)
}
fn default_db_test_before_acquire() -> bool {
true
}
fn default_trusted_proxy_hops() -> usize {
0
}
pub fn client_ip(headers: &crate::web::HeaderMap) -> Option<String> {
let hops = get_opt().map(|s| s.trusted_proxy_hops).unwrap_or(0);
client_ip_with_hops(headers, hops)
}
fn client_ip_with_hops(headers: &crate::web::HeaderMap, hops: usize) -> Option<String> {
if hops == 0 {
return None;
}
let xff = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())?;
let chain: Vec<&str> = xff
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
let idx = chain.len().checked_sub(hops)?;
chain
.get(idx)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
fn default_bind_addr() -> String {
"127.0.0.1:8000".into()
}
fn default_static_url() -> String {
"/static/".into()
}
fn default_static_root() -> String {
"staticfiles/".into()
}
fn normalize_static_url(raw: &str) -> String {
let trimmed = raw.trim();
let is_absolute = trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
|| trimmed.starts_with("//");
let mut out = String::with_capacity(trimmed.len() + 2);
if is_absolute {
out.push_str(trimmed.trim_end_matches('/'));
} else {
out.push('/');
out.push_str(trimmed.trim_matches('/'));
}
if !out.ends_with('/') {
out.push('/');
}
out
}
fn deserialize_static_url<'de, D>(de: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(de)?;
Ok(normalize_static_url(&raw))
}
fn deserialize_zero_as_none<'de, D>(de: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Int(u64),
Str(String),
Null,
}
let value = match Option::<Raw>::deserialize(de)? {
None | Some(Raw::Null) => return Ok(None),
Some(Raw::Int(n)) => n,
Some(Raw::Str(s)) => {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(None);
}
trimmed.parse::<u64>().map_err(D::Error::custom)?
}
};
Ok(if value == 0 { None } else { Some(value) })
}
fn deserialize_environment<'de, D>(de: D) -> Result<Environment, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let raw = String::deserialize(de)?;
match raw.trim().to_ascii_lowercase().as_str() {
"dev" | "development" => Ok(Environment::Dev),
"test" | "testing" => Ok(Environment::Test),
"prod" | "production" => Ok(Environment::Prod),
other => Err(D::Error::custom(format!(
"unknown environment `{other}`; expected one of Dev, Test, Prod (case-insensitive)"
))),
}
}
fn dotenv_key(key: &str) -> Option<String> {
const PREFIX: &str = "UMBRAL_";
let key = key.trim();
if key.len() <= PREFIX.len() || !key.get(..PREFIX.len())?.eq_ignore_ascii_case(PREFIX) {
return None;
}
let key = key[PREFIX.len()..].replace("__", ".").to_ascii_lowercase();
if key.split('.').any(str::is_empty) {
return None;
}
Some(key)
}
fn merge_dotenv(mut figment: Figment) -> Figment {
let Ok(iter) = dotenvy::from_filename_iter(".env") else {
return figment;
};
let mut seen = HashSet::new();
for (key, value) in iter.flatten() {
let Some(key) = dotenv_key(&key) else {
continue;
};
if !seen.insert(key.clone()) {
continue;
}
let value = value
.parse::<figment::value::Value>()
.expect("figment value parsing is infallible");
figment = figment.merge(Serialized::default(&key, value));
}
figment
}
#[derive(Clone, Deserialize)]
pub struct Settings {
#[serde(default = "default_database_url")]
pub database_url: String,
#[serde(default)]
pub databases: std::collections::HashMap<String, String>,
#[serde(default = "default_max_form_body_bytes")]
pub max_form_body_bytes: Option<usize>,
#[serde(default = "default_db_max_connections")]
pub db_max_connections: u32,
#[serde(default = "default_db_acquire_timeout_secs")]
pub db_acquire_timeout_secs: u64,
#[serde(default = "default_db_min_connections")]
pub db_min_connections: u32,
#[serde(
default = "default_db_idle_timeout_secs",
deserialize_with = "deserialize_zero_as_none"
)]
pub db_idle_timeout_secs: Option<u64>,
#[serde(
default = "default_db_max_lifetime_secs",
deserialize_with = "deserialize_zero_as_none"
)]
pub db_max_lifetime_secs: Option<u64>,
#[serde(default = "default_db_test_before_acquire")]
pub db_test_before_acquire: bool,
#[serde(default = "default_secret_key")]
pub secret_key: String,
#[serde(default, deserialize_with = "deserialize_environment")]
pub environment: Environment,
#[serde(
default = "default_allowed_hosts",
deserialize_with = "deserialize_string_list"
)]
pub allowed_hosts: Vec<String>,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_trusted_proxy_hops")]
pub trusted_proxy_hops: usize,
#[serde(default = "default_bind_addr")]
pub bind_addr: String,
#[serde(default)]
pub time_zone: Option<String>,
#[serde(
default = "default_static_url",
deserialize_with = "deserialize_static_url"
)]
pub static_url: String,
#[serde(default = "default_static_root")]
pub static_root: String,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, toml::Value>,
}
fn redact_url_userinfo(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let after = scheme_end + 3;
let authority_end = url[after..]
.find(['/', '?', '#'])
.map(|i| after + i)
.unwrap_or(url.len());
match url[after..authority_end].find('@') {
Some(at) => format!("{}***{}", &url[..after], &url[after + at..]),
None => url.to_string(),
}
}
struct RedactedDatabases<'a>(&'a std::collections::HashMap<String, String>);
impl std::fmt::Debug for RedactedDatabases<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_map()
.entries(self.0.iter().map(|(k, v)| (k, redact_url_userinfo(v))))
.finish()
}
}
struct RedactedExtra<'a>(&'a std::collections::HashMap<String, toml::Value>);
impl std::fmt::Debug for RedactedExtra<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_map()
.entries(self.0.keys().map(|k| (k, "***")))
.finish()
}
}
impl std::fmt::Debug for Settings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Settings")
.field("database_url", &redact_url_userinfo(&self.database_url))
.field("databases", &RedactedDatabases(&self.databases))
.field("max_form_body_bytes", &self.max_form_body_bytes)
.field("db_max_connections", &self.db_max_connections)
.field("db_acquire_timeout_secs", &self.db_acquire_timeout_secs)
.field("db_min_connections", &self.db_min_connections)
.field("db_idle_timeout_secs", &self.db_idle_timeout_secs)
.field("db_max_lifetime_secs", &self.db_max_lifetime_secs)
.field("db_test_before_acquire", &self.db_test_before_acquire)
.field("secret_key", &"***redacted***")
.field("environment", &self.environment)
.field("allowed_hosts", &self.allowed_hosts)
.field("log_level", &self.log_level)
.field("bind_addr", &self.bind_addr)
.field("time_zone", &self.time_zone)
.field("static_url", &self.static_url)
.field("static_root", &self.static_root)
.field("extra", &RedactedExtra(&self.extra))
.finish()
}
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
pub enum Environment {
Dev,
Test,
Prod,
}
impl Default for Environment {
fn default() -> Self {
if cfg!(debug_assertions) {
Environment::Dev
} else {
Environment::Prod
}
}
}
impl Settings {
pub fn extra_str(&self, key: &str) -> Option<&str> {
self.extra.get(key).and_then(|v| v.as_str())
}
pub fn from_env() -> Result<Self, Box<figment::Error>> {
let settings: Settings = merge_dotenv(Figment::new().merge(Toml::file("umbral.toml")))
.merge(Env::prefixed("UMBRAL_").split("__"))
.extract()
.map_err(Box::new)?;
warn_on_near_miss_keys(&settings.extra);
warn_on_legacy_umbra_prefix();
Ok(settings)
}
}
fn warn_on_legacy_umbra_prefix() {
let legacy: Vec<String> = std::env::vars()
.map(|(k, _)| k)
.filter(|k| k.starts_with("UMBRA_") && !k.starts_with("UMBRAL_"))
.collect();
if legacy.is_empty() {
return;
}
let renamed: Vec<String> = legacy
.iter()
.map(|k| k.replacen("UMBRA_", "UMBRAL_", 1))
.collect();
tracing::warn!(
"umbral: {} environment variable(s) use the OLD `UMBRA_` prefix and are being \
IGNORED: {legacy:?}. The prefix is now `UMBRAL_` — rename them to {renamed:?}. \
Until you do, each of these settings silently falls back to its DEFAULT, and the \
default `database_url` is `sqlite::memory:` — an in-memory database that is \
discarded on exit, against which `migrate` will cheerfully report success and \
persist nothing.",
legacy.len(),
);
}
const KNOWN_SETTINGS_KEYS: &[&str] = &[
"database_url",
"databases",
"max_form_body_bytes",
"db_max_connections",
"db_acquire_timeout_secs",
"db_min_connections",
"db_idle_timeout_secs",
"db_max_lifetime_secs",
"db_test_before_acquire",
"secret_key",
"environment",
"allowed_hosts",
"log_level",
"bind_addr",
"time_zone",
"static_url",
"static_root",
];
fn levenshtein(a: &str, b: &str) -> usize {
let (a, b) = (a.as_bytes(), b.as_bytes());
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
fn warn_on_near_miss_keys(extra: &std::collections::HashMap<String, toml::Value>) {
for key in extra.keys() {
let key_l = key.to_ascii_lowercase();
if let Some((known, dist)) = KNOWN_SETTINGS_KEYS
.iter()
.map(|k| (*k, levenshtein(&key_l, k)))
.min_by_key(|(_, d)| *d)
&& (1..=2).contains(&dist)
{
tracing::warn!(
key = %key,
did_you_mean = %known,
"settings: `UMBRAL_{}` is not a known framework key but is very close to \
`UMBRAL_{}` — did you mean that? It was accepted as an app-defined value \
in `extra` and will NOT configure the framework.",
key_l.to_ascii_uppercase(),
known.to_ascii_uppercase(),
);
}
}
}
#[cfg(test)]
#[allow(clippy::result_large_err)]
mod tests {
use super::*;
#[test]
fn client_ip_honors_trusted_proxy_hops() {
use super::client_ip_with_hops;
fn hdrs(xff: Option<&str>) -> crate::web::HeaderMap {
let mut h = crate::web::HeaderMap::new();
if let Some(v) = xff {
h.insert("x-forwarded-for", v.parse().unwrap());
}
h
}
assert_eq!(client_ip_with_hops(&hdrs(Some("1.2.3.4")), 0), None);
assert_eq!(client_ip_with_hops(&hdrs(None), 0), None);
assert_eq!(
client_ip_with_hops(&hdrs(Some("203.0.113.7")), 1).as_deref(),
Some("203.0.113.7")
);
assert_eq!(
client_ip_with_hops(&hdrs(Some("9.9.9.9, 203.0.113.7")), 1).as_deref(),
Some("203.0.113.7")
);
assert_eq!(
client_ip_with_hops(&hdrs(Some("9.9.9.9, real, proxy1")), 2).as_deref(),
Some("real")
);
assert_eq!(client_ip_with_hops(&hdrs(Some("only-one")), 2), None);
assert_eq!(client_ip_with_hops(&hdrs(None), 1), None);
}
#[test]
fn misspelled_framework_keys_are_near_misses() {
for (typo, target) in [
("alowed_hosts", "allowed_hosts"),
("db_max_connection", "db_max_connections"),
("secret_ky", "secret_key"),
("enviroment", "environment"),
] {
let d = levenshtein(typo, target);
assert!((1..=2).contains(&d), "`{typo}` vs `{target}`: distance {d}");
}
for app_key in ["openai_api_key", "stripe_secret", "sentry_dsn"] {
let min = KNOWN_SETTINGS_KEYS
.iter()
.map(|k| levenshtein(app_key, k))
.min()
.unwrap();
assert!(min > 2, "`{app_key}` should not be a near-miss (min {min})");
}
}
use figment::Jail;
#[test]
fn defaults_apply_when_nothing_is_set() {
Jail::expect_with(|_| {
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "sqlite::memory:");
assert_eq!(s.secret_key, "umbral-insecure-dev-key-change-me");
assert_eq!(s.allowed_hosts, vec!["localhost", "127.0.0.1"]);
assert_eq!(s.log_level, "info");
assert!(matches!(s.environment, Environment::Dev));
assert!(s.databases.is_empty());
Ok(())
});
}
#[test]
fn allowed_hosts_accepts_comma_separated_env() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com, www.example.com");
let s = Settings::from_env().unwrap();
assert_eq!(s.allowed_hosts, vec!["example.com", "www.example.com"]);
Ok(())
});
}
#[test]
fn allowed_hosts_accepts_single_env_value() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com");
let s = Settings::from_env().unwrap();
assert_eq!(s.allowed_hosts, vec!["example.com"]);
Ok(())
});
}
#[test]
fn allowed_hosts_accepts_bracketed_env_and_toml_array() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ALLOWED_HOSTS", r#"["a.com","b.com"]"#);
assert_eq!(
Settings::from_env().unwrap().allowed_hosts,
vec!["a.com", "b.com"]
);
Ok(())
});
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"allowed_hosts = ["a.com", "b.com"]"#)?;
assert_eq!(
Settings::from_env().unwrap().allowed_hosts,
vec!["a.com", "b.com"]
);
Ok(())
});
}
#[test]
fn umbral_env_var_overrides_database_url() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DATABASE_URL", "postgres://example");
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "postgres://example");
Ok(())
});
}
#[test]
fn nested_env_var_populates_databases_map() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DATABASES__REPLICA", "sqlite://replica.db");
let s = Settings::from_env().unwrap();
assert_eq!(
s.databases.get("replica").map(String::as_str),
Some("sqlite://replica.db"),
);
Ok(())
});
}
#[test]
fn umbral_toml_in_cwd_is_loaded() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
let s = Settings::from_env().unwrap();
assert_eq!(s.secret_key, "from-toml");
Ok(())
});
}
#[test]
fn env_var_overrides_toml() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
jail.set_env("UMBRAL_SECRET_KEY", "from-env");
let s = Settings::from_env().unwrap();
assert_eq!(s.secret_key, "from-env");
Ok(())
});
}
#[test]
fn dotenv_file_overrides_toml() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"database_url = "sqlite://from-toml.db""#)?;
jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "postgres://from-dotenv");
Ok(())
});
}
#[test]
fn dotenv_file_populates_nested_databases_map() {
Jail::expect_with(|jail| {
jail.create_file(".env", "UMBRAL_DATABASES__REPLICA=sqlite://replica.db\n")?;
let s = Settings::from_env().unwrap();
assert_eq!(
s.databases.get("replica").map(String::as_str),
Some("sqlite://replica.db"),
);
Ok(())
});
}
#[test]
fn process_env_overrides_dotenv_file() {
Jail::expect_with(|jail| {
jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
jail.set_env("UMBRAL_DATABASE_URL", "postgres://from-process-env");
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "postgres://from-process-env");
Ok(())
});
}
#[test]
fn static_url_and_root_defaults() {
Jail::expect_with(|_| {
let s = Settings::from_env().unwrap();
assert_eq!(s.static_url, "/static/");
assert_eq!(s.static_root, "staticfiles/");
Ok(())
});
}
#[test]
fn static_url_env_override_is_normalised() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_URL", "/assets");
assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
Ok(())
});
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_URL", "assets");
assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
Ok(())
});
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_URL", "/assets/");
assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
Ok(())
});
}
#[test]
fn static_url_normalises_three_input_shapes() {
assert_eq!(normalize_static_url("/static"), "/static/");
assert_eq!(normalize_static_url("static"), "/static/");
assert_eq!(normalize_static_url("/static/"), "/static/");
}
#[test]
fn static_url_cdn_origin_keeps_scheme_and_host() {
assert_eq!(
normalize_static_url("https://cdn.example.com/s"),
"https://cdn.example.com/s/"
);
assert_eq!(
normalize_static_url("https://cdn.example.com/s/"),
"https://cdn.example.com/s/"
);
}
#[test]
fn static_root_env_override() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_ROOT", "build/assets/");
assert_eq!(Settings::from_env().unwrap().static_root, "build/assets/");
Ok(())
});
}
#[test]
fn db_pool_defaults_apply_when_nothing_is_set() {
Jail::expect_with(|_| {
let s = Settings::from_env().unwrap();
assert_eq!(s.db_max_connections, 10);
assert_eq!(s.db_min_connections, 0);
assert_eq!(s.db_acquire_timeout_secs, 30);
assert_eq!(s.db_idle_timeout_secs, Some(600));
assert_eq!(s.db_max_lifetime_secs, Some(1800));
assert!(s.db_test_before_acquire);
Ok(())
});
}
#[test]
fn db_pool_env_overrides_each_knob() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DB_MAX_CONNECTIONS", "42");
jail.set_env("UMBRAL_DB_MIN_CONNECTIONS", "4");
jail.set_env("UMBRAL_DB_ACQUIRE_TIMEOUT_SECS", "7");
jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "120");
jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "240");
jail.set_env("UMBRAL_DB_TEST_BEFORE_ACQUIRE", "false");
let s = Settings::from_env().unwrap();
assert_eq!(s.db_max_connections, 42);
assert_eq!(s.db_min_connections, 4);
assert_eq!(s.db_acquire_timeout_secs, 7);
assert_eq!(s.db_idle_timeout_secs, Some(120));
assert_eq!(s.db_max_lifetime_secs, Some(240));
assert!(!s.db_test_before_acquire);
Ok(())
});
}
#[test]
fn db_timeout_zero_means_disabled_none() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "0");
jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "0");
let s = Settings::from_env().unwrap();
assert_eq!(s.db_idle_timeout_secs, None);
assert_eq!(s.db_max_lifetime_secs, None);
Ok(())
});
}
#[test]
fn db_timeout_empty_string_means_disabled_none() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "");
let s = Settings::from_env().unwrap();
assert_eq!(s.db_idle_timeout_secs, None);
Ok(())
});
}
#[test]
fn environment_default_is_profile_aware() {
let d = Environment::default();
if cfg!(debug_assertions) {
assert!(
matches!(d, Environment::Dev),
"debug build must default to Dev"
);
} else {
assert!(
matches!(d, Environment::Prod),
"release build must default to Prod (H14 secure-by-default)"
);
}
}
#[test]
fn environment_prod_round_trips_through_toml() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"environment = "Prod""#)?;
let s = Settings::from_env().unwrap();
assert!(matches!(s.environment, Environment::Prod));
Ok(())
});
}
#[test]
fn environment_is_case_insensitive() {
for value in ["prod", "PROD", "Production", "production"] {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ENVIRONMENT", value);
let s = Settings::from_env().unwrap();
assert!(
matches!(s.environment, Environment::Prod),
"`{value}` should deserialize to Prod",
);
Ok(())
});
}
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ENVIRONMENT", "test");
assert!(matches!(
Settings::from_env().unwrap().environment,
Environment::Test
));
Ok(())
});
}
#[test]
fn environment_rejects_unknown_value() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ENVIRONMENT", "staging");
assert!(
Settings::from_env().is_err(),
"an unknown environment must still be a load error"
);
Ok(())
});
}
#[test]
fn debug_redacts_secrets() {
let mut databases = std::collections::HashMap::new();
databases.insert(
"replica".to_string(),
"postgres://ruser:rpass@replica.host/app".to_string(),
);
let mut extra = std::collections::HashMap::new();
extra.insert(
"stripe_secret".to_string(),
toml::Value::String("sk_live_TOPSECRET".to_string()),
);
let settings = Settings {
database_url: "postgres://alice:hunter2@db.host:5432/app".to_string(),
databases,
max_form_body_bytes: Some(1024),
db_max_connections: 10,
db_acquire_timeout_secs: 30,
db_min_connections: 0,
db_idle_timeout_secs: Some(600),
db_max_lifetime_secs: Some(1800),
db_test_before_acquire: true,
secret_key: "SUPERSECRETKEYVALUE-do-not-leak".to_string(),
environment: Environment::Prod,
allowed_hosts: vec!["example.com".to_string()],
log_level: "info".to_string(),
bind_addr: "127.0.0.1:8000".to_string(),
trusted_proxy_hops: 0,
time_zone: None,
static_url: "/static/".to_string(),
static_root: "staticfiles/".to_string(),
extra,
};
let rendered = format!("{settings:?}");
assert!(
!rendered.contains("SUPERSECRETKEYVALUE"),
"secret_key leaked: {rendered}"
);
assert!(
!rendered.contains("hunter2"),
"database_url password leaked: {rendered}"
);
assert!(
!rendered.contains("rpass"),
"databases password leaked: {rendered}"
);
assert!(
!rendered.contains("sk_live_TOPSECRET"),
"extra value leaked: {rendered}"
);
assert!(
rendered.contains("db.host"),
"host should survive redaction"
);
assert!(
rendered.contains("stripe_secret"),
"extra keys stay visible to spot typos"
);
}
#[test]
fn redact_url_userinfo_masks_password_keeps_host() {
assert_eq!(
redact_url_userinfo("postgres://alice:hunter2@db.host/app"),
"postgres://***@db.host/app"
);
assert_eq!(redact_url_userinfo("sqlite::memory:"), "sqlite::memory:");
assert_eq!(
redact_url_userinfo("sqlite://data/app.db"),
"sqlite://data/app.db"
);
}
#[test]
fn unknown_env_var_is_captured_in_extra() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_OPENAI_API_KEY", "sk-test-12345");
let s = Settings::from_env().unwrap();
assert_eq!(s.extra_str("openai_api_key"), Some("sk-test-12345"));
assert_eq!(s.database_url, "sqlite::memory:");
Ok(())
});
}
#[test]
fn unknown_toml_table_is_captured_in_extra() {
Jail::expect_with(|jail| {
jail.create_file(
"umbral.toml",
r#"
[external]
provider = "stripe"
"#,
)?;
let s = Settings::from_env().unwrap();
let provider = s
.extra
.get("external")
.and_then(|v| v.get("provider"))
.and_then(|v| v.as_str());
assert_eq!(provider, Some("stripe"));
Ok(())
});
}
}