1use figment::Figment;
2use figment::providers::{Env, Format, Serialized, Toml};
3use serde::Deserialize;
4use std::collections::HashSet;
5use std::sync::OnceLock;
6
7pub(crate) static SETTINGS: OnceLock<Settings> = OnceLock::new();
9
10pub(crate) fn init(settings: &Settings) {
12 SETTINGS
16 .set(settings.clone())
17 .expect("umbral::settings::init called more than once");
18}
19
20pub fn get() -> &'static Settings {
26 SETTINGS
27 .get()
28 .expect("umbral: settings not initialised — did you call App::build()?")
29}
30
31pub fn get_opt() -> Option<&'static Settings> {
37 SETTINGS.get()
38}
39
40fn default_database_url() -> String {
41 "sqlite::memory:".into()
46}
47
48fn default_max_form_body_bytes() -> Option<usize> {
52 Some(16 * 1024 * 1024)
53}
54
55fn default_secret_key() -> String {
56 "umbral-insecure-dev-key-change-me".into()
57}
58
59fn default_allowed_hosts() -> Vec<String> {
60 vec!["localhost".into(), "127.0.0.1".into()]
61}
62
63fn deserialize_string_list<'de, D>(de: D) -> Result<Vec<String>, D::Error>
70where
71 D: serde::Deserializer<'de>,
72{
73 use serde::Deserialize;
74 #[derive(Deserialize)]
75 #[serde(untagged)]
76 enum OneOrMany {
77 One(String),
78 Many(Vec<String>),
79 }
80 Ok(match OneOrMany::deserialize(de)? {
81 OneOrMany::One(s) => s
82 .split(',')
83 .map(str::trim)
84 .filter(|h| !h.is_empty())
85 .map(str::to_string)
86 .collect(),
87 OneOrMany::Many(v) => v,
88 })
89}
90
91fn default_log_level() -> String {
92 "info".into()
93}
94
95fn default_db_max_connections() -> u32 {
98 10
99}
100
101fn default_db_acquire_timeout_secs() -> u64 {
105 30
106}
107
108fn default_db_min_connections() -> u32 {
112 0
113}
114
115fn default_db_idle_timeout_secs() -> Option<u64> {
120 Some(600)
121}
122
123fn default_db_max_lifetime_secs() -> Option<u64> {
129 Some(1800)
130}
131
132fn default_db_test_before_acquire() -> bool {
138 true
139}
140
141fn default_trusted_proxy_hops() -> usize {
142 0
143}
144
145pub fn client_ip(headers: &crate::web::HeaderMap) -> Option<String> {
156 let hops = get_opt().map(|s| s.trusted_proxy_hops).unwrap_or(0);
157 client_ip_with_hops(headers, hops)
158}
159
160fn client_ip_with_hops(headers: &crate::web::HeaderMap, hops: usize) -> Option<String> {
164 if hops == 0 {
165 return None;
166 }
167 let xff = headers
168 .get("x-forwarded-for")
169 .and_then(|v| v.to_str().ok())?;
170 let chain: Vec<&str> = xff
171 .split(',')
172 .map(str::trim)
173 .filter(|s| !s.is_empty())
174 .collect();
175 let idx = chain.len().checked_sub(hops)?;
182 chain
183 .get(idx)
184 .filter(|s| !s.is_empty())
185 .map(|s| s.to_string())
186}
187
188fn default_bind_addr() -> String {
189 "127.0.0.1:8000".into()
193}
194
195fn default_static_url() -> String {
196 "/static/".into()
197}
198
199fn default_static_root() -> String {
200 "staticfiles/".into()
201}
202
203fn normalize_static_url(raw: &str) -> String {
214 let trimmed = raw.trim();
215 let is_absolute = trimmed.starts_with("http://")
216 || trimmed.starts_with("https://")
217 || trimmed.starts_with("//");
218
219 let mut out = String::with_capacity(trimmed.len() + 2);
220 if is_absolute {
221 out.push_str(trimmed.trim_end_matches('/'));
222 } else {
223 out.push('/');
224 out.push_str(trimmed.trim_matches('/'));
225 }
226 if !out.ends_with('/') {
227 out.push('/');
228 }
229 out
230}
231
232fn deserialize_static_url<'de, D>(de: D) -> Result<String, D::Error>
237where
238 D: serde::Deserializer<'de>,
239{
240 let raw = String::deserialize(de)?;
241 Ok(normalize_static_url(&raw))
242}
243
244fn deserialize_zero_as_none<'de, D>(de: D) -> Result<Option<u64>, D::Error>
249where
250 D: serde::Deserializer<'de>,
251{
252 use serde::de::Error as _;
253
254 #[derive(Deserialize)]
255 #[serde(untagged)]
256 enum Raw {
257 Int(u64),
258 Str(String),
259 Null,
260 }
261
262 let value = match Option::<Raw>::deserialize(de)? {
263 None | Some(Raw::Null) => return Ok(None),
264 Some(Raw::Int(n)) => n,
265 Some(Raw::Str(s)) => {
266 let trimmed = s.trim();
267 if trimmed.is_empty() {
268 return Ok(None);
269 }
270 trimmed.parse::<u64>().map_err(D::Error::custom)?
271 }
272 };
273
274 Ok(if value == 0 { None } else { Some(value) })
275}
276
277fn deserialize_environment<'de, D>(de: D) -> Result<Environment, D::Error>
284where
285 D: serde::Deserializer<'de>,
286{
287 use serde::de::Error as _;
288 let raw = String::deserialize(de)?;
289 match raw.trim().to_ascii_lowercase().as_str() {
290 "dev" | "development" => Ok(Environment::Dev),
291 "test" | "testing" => Ok(Environment::Test),
292 "prod" | "production" => Ok(Environment::Prod),
293 other => Err(D::Error::custom(format!(
294 "unknown environment `{other}`; expected one of Dev, Test, Prod (case-insensitive)"
295 ))),
296 }
297}
298
299fn dotenv_key(key: &str) -> Option<String> {
300 const PREFIX: &str = "UMBRAL_";
301
302 let key = key.trim();
303 if key.len() <= PREFIX.len() || !key.get(..PREFIX.len())?.eq_ignore_ascii_case(PREFIX) {
304 return None;
305 }
306
307 let key = key[PREFIX.len()..].replace("__", ".").to_ascii_lowercase();
308 if key.split('.').any(str::is_empty) {
309 return None;
310 }
311
312 Some(key)
313}
314
315fn merge_dotenv(mut figment: Figment) -> Figment {
316 let Ok(iter) = dotenvy::from_filename_iter(".env") else {
317 return figment;
318 };
319 let mut seen = HashSet::new();
320
321 for (key, value) in iter.flatten() {
322 let Some(key) = dotenv_key(&key) else {
323 continue;
324 };
325 if !seen.insert(key.clone()) {
326 continue;
327 }
328 let value = value
329 .parse::<figment::value::Value>()
330 .expect("figment value parsing is infallible");
331 figment = figment.merge(Serialized::default(&key, value));
332 }
333
334 figment
335}
336
337#[derive(Clone, Deserialize)]
338pub struct Settings {
339 #[serde(default = "default_database_url")]
340 pub database_url: String,
341
342 #[serde(default)]
343 pub databases: std::collections::HashMap<String, String>,
344
345 #[serde(default = "default_max_form_body_bytes")]
352 pub max_form_body_bytes: Option<usize>,
353
354 #[serde(default = "default_db_max_connections")]
357 pub db_max_connections: u32,
358
359 #[serde(default = "default_db_acquire_timeout_secs")]
363 pub db_acquire_timeout_secs: u64,
364
365 #[serde(default = "default_db_min_connections")]
369 pub db_min_connections: u32,
370
371 #[serde(
375 default = "default_db_idle_timeout_secs",
376 deserialize_with = "deserialize_zero_as_none"
377 )]
378 pub db_idle_timeout_secs: Option<u64>,
379
380 #[serde(
385 default = "default_db_max_lifetime_secs",
386 deserialize_with = "deserialize_zero_as_none"
387 )]
388 pub db_max_lifetime_secs: Option<u64>,
389
390 #[serde(default = "default_db_test_before_acquire")]
394 pub db_test_before_acquire: bool,
395
396 #[serde(default = "default_secret_key")]
397 pub secret_key: String,
398
399 #[serde(default, deserialize_with = "deserialize_environment")]
400 pub environment: Environment,
401
402 #[serde(
403 default = "default_allowed_hosts",
404 deserialize_with = "deserialize_string_list"
405 )]
406 pub allowed_hosts: Vec<String>,
407
408 #[serde(default = "default_log_level")]
409 pub log_level: String,
410
411 #[serde(default = "default_trusted_proxy_hops")]
427 pub trusted_proxy_hops: usize,
428
429 #[serde(default = "default_bind_addr")]
433 pub bind_addr: String,
434
435 #[serde(default)]
456 pub time_zone: Option<String>,
457
458 #[serde(
473 default = "default_static_url",
474 deserialize_with = "deserialize_static_url"
475 )]
476 pub static_url: String,
477
478 #[serde(default = "default_static_root")]
487 pub static_root: String,
488
489 #[serde(flatten)]
503 pub extra: std::collections::HashMap<String, toml::Value>,
504}
505
506fn redact_url_userinfo(url: &str) -> String {
512 let Some(scheme_end) = url.find("://") else {
513 return url.to_string();
514 };
515 let after = scheme_end + 3;
516 let authority_end = url[after..]
519 .find(['/', '?', '#'])
520 .map(|i| after + i)
521 .unwrap_or(url.len());
522 match url[after..authority_end].find('@') {
523 Some(at) => format!("{}***{}", &url[..after], &url[after + at..]),
524 None => url.to_string(),
525 }
526}
527
528struct RedactedDatabases<'a>(&'a std::collections::HashMap<String, String>);
531
532impl std::fmt::Debug for RedactedDatabases<'_> {
533 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534 f.debug_map()
535 .entries(self.0.iter().map(|(k, v)| (k, redact_url_userinfo(v))))
536 .finish()
537 }
538}
539
540struct RedactedExtra<'a>(&'a std::collections::HashMap<String, toml::Value>);
544
545impl std::fmt::Debug for RedactedExtra<'_> {
546 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 f.debug_map()
548 .entries(self.0.keys().map(|k| (k, "***")))
549 .finish()
550 }
551}
552
553impl std::fmt::Debug for Settings {
560 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561 f.debug_struct("Settings")
562 .field("database_url", &redact_url_userinfo(&self.database_url))
563 .field("databases", &RedactedDatabases(&self.databases))
564 .field("max_form_body_bytes", &self.max_form_body_bytes)
565 .field("db_max_connections", &self.db_max_connections)
566 .field("db_acquire_timeout_secs", &self.db_acquire_timeout_secs)
567 .field("db_min_connections", &self.db_min_connections)
568 .field("db_idle_timeout_secs", &self.db_idle_timeout_secs)
569 .field("db_max_lifetime_secs", &self.db_max_lifetime_secs)
570 .field("db_test_before_acquire", &self.db_test_before_acquire)
571 .field("secret_key", &"***redacted***")
572 .field("environment", &self.environment)
573 .field("allowed_hosts", &self.allowed_hosts)
574 .field("log_level", &self.log_level)
575 .field("bind_addr", &self.bind_addr)
576 .field("time_zone", &self.time_zone)
577 .field("static_url", &self.static_url)
578 .field("static_root", &self.static_root)
579 .field("extra", &RedactedExtra(&self.extra))
580 .finish()
581 }
582}
583
584#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
589pub enum Environment {
590 Dev,
591 Test,
592 Prod,
593}
594
595impl Default for Environment {
596 fn default() -> Self {
605 if cfg!(debug_assertions) {
606 Environment::Dev
607 } else {
608 Environment::Prod
609 }
610 }
611}
612
613impl Settings {
614 pub fn extra_str(&self, key: &str) -> Option<&str> {
623 self.extra.get(key).and_then(|v| v.as_str())
624 }
625
626 pub fn from_env() -> Result<Self, Box<figment::Error>> {
639 let settings: Settings = merge_dotenv(Figment::new().merge(Toml::file("umbral.toml")))
640 .merge(Env::prefixed("UMBRAL_").split("__"))
641 .extract()
642 .map_err(Box::new)?;
643 warn_on_near_miss_keys(&settings.extra);
644 warn_on_legacy_umbra_prefix();
645 Ok(settings)
646 }
647}
648
649fn warn_on_legacy_umbra_prefix() {
665 let legacy: Vec<String> = std::env::vars()
666 .map(|(k, _)| k)
667 .filter(|k| k.starts_with("UMBRA_") && !k.starts_with("UMBRAL_"))
668 .collect();
669 if legacy.is_empty() {
670 return;
671 }
672 let renamed: Vec<String> = legacy
673 .iter()
674 .map(|k| k.replacen("UMBRA_", "UMBRAL_", 1))
675 .collect();
676 tracing::warn!(
677 "umbral: {} environment variable(s) use the OLD `UMBRA_` prefix and are being \
678 IGNORED: {legacy:?}. The prefix is now `UMBRAL_` — rename them to {renamed:?}. \
679 Until you do, each of these settings silently falls back to its DEFAULT, and the \
680 default `database_url` is `sqlite::memory:` — an in-memory database that is \
681 discarded on exit, against which `migrate` will cheerfully report success and \
682 persist nothing.",
683 legacy.len(),
684 );
685}
686
687const KNOWN_SETTINGS_KEYS: &[&str] = &[
692 "database_url",
693 "databases",
694 "max_form_body_bytes",
695 "db_max_connections",
696 "db_acquire_timeout_secs",
697 "db_min_connections",
698 "db_idle_timeout_secs",
699 "db_max_lifetime_secs",
700 "db_test_before_acquire",
701 "secret_key",
702 "environment",
703 "allowed_hosts",
704 "log_level",
705 "bind_addr",
706 "time_zone",
707 "static_url",
708 "static_root",
709];
710
711fn levenshtein(a: &str, b: &str) -> usize {
714 let (a, b) = (a.as_bytes(), b.as_bytes());
715 let mut prev: Vec<usize> = (0..=b.len()).collect();
716 let mut curr: Vec<usize> = vec![0; b.len() + 1];
717 for (i, &ca) in a.iter().enumerate() {
718 curr[0] = i + 1;
719 for (j, &cb) in b.iter().enumerate() {
720 let cost = usize::from(ca != cb);
721 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
722 }
723 std::mem::swap(&mut prev, &mut curr);
724 }
725 prev[b.len()]
726}
727
728fn warn_on_near_miss_keys(extra: &std::collections::HashMap<String, toml::Value>) {
733 for key in extra.keys() {
734 let key_l = key.to_ascii_lowercase();
735 if let Some((known, dist)) = KNOWN_SETTINGS_KEYS
736 .iter()
737 .map(|k| (*k, levenshtein(&key_l, k)))
738 .min_by_key(|(_, d)| *d)
739 && (1..=2).contains(&dist)
740 {
741 tracing::warn!(
742 key = %key,
743 did_you_mean = %known,
744 "settings: `UMBRAL_{}` is not a known framework key but is very close to \
745 `UMBRAL_{}` — did you mean that? It was accepted as an app-defined value \
746 in `extra` and will NOT configure the framework.",
747 key_l.to_ascii_uppercase(),
748 known.to_ascii_uppercase(),
749 );
750 }
751 }
752}
753
754#[cfg(test)]
755#[allow(clippy::result_large_err)]
756mod tests {
760 use super::*;
765
766 #[test]
768 fn client_ip_honors_trusted_proxy_hops() {
769 use super::client_ip_with_hops;
770 fn hdrs(xff: Option<&str>) -> crate::web::HeaderMap {
771 let mut h = crate::web::HeaderMap::new();
772 if let Some(v) = xff {
773 h.insert("x-forwarded-for", v.parse().unwrap());
774 }
775 h
776 }
777
778 assert_eq!(client_ip_with_hops(&hdrs(Some("1.2.3.4")), 0), None);
780 assert_eq!(client_ip_with_hops(&hdrs(None), 0), None);
781
782 assert_eq!(
785 client_ip_with_hops(&hdrs(Some("203.0.113.7")), 1).as_deref(),
786 Some("203.0.113.7")
787 );
788 assert_eq!(
791 client_ip_with_hops(&hdrs(Some("9.9.9.9, 203.0.113.7")), 1).as_deref(),
792 Some("203.0.113.7")
793 );
794
795 assert_eq!(
798 client_ip_with_hops(&hdrs(Some("9.9.9.9, real, proxy1")), 2).as_deref(),
799 Some("real")
800 );
801
802 assert_eq!(client_ip_with_hops(&hdrs(Some("only-one")), 2), None);
804 assert_eq!(client_ip_with_hops(&hdrs(None), 1), None);
805 }
806
807 #[test]
810 fn misspelled_framework_keys_are_near_misses() {
811 for (typo, target) in [
812 ("alowed_hosts", "allowed_hosts"),
813 ("db_max_connection", "db_max_connections"),
814 ("secret_ky", "secret_key"),
815 ("enviroment", "environment"),
816 ] {
817 let d = levenshtein(typo, target);
818 assert!((1..=2).contains(&d), "`{typo}` vs `{target}`: distance {d}");
819 }
820 for app_key in ["openai_api_key", "stripe_secret", "sentry_dsn"] {
822 let min = KNOWN_SETTINGS_KEYS
823 .iter()
824 .map(|k| levenshtein(app_key, k))
825 .min()
826 .unwrap();
827 assert!(min > 2, "`{app_key}` should not be a near-miss (min {min})");
828 }
829 }
830 use figment::Jail;
831
832 #[test]
833 fn defaults_apply_when_nothing_is_set() {
834 Jail::expect_with(|_| {
835 let s = Settings::from_env().unwrap();
836 assert_eq!(s.database_url, "sqlite::memory:");
837 assert_eq!(s.secret_key, "umbral-insecure-dev-key-change-me");
838 assert_eq!(s.allowed_hosts, vec!["localhost", "127.0.0.1"]);
839 assert_eq!(s.log_level, "info");
840 assert!(matches!(s.environment, Environment::Dev));
841 assert!(s.databases.is_empty());
842 Ok(())
843 });
844 }
845
846 #[test]
847 fn allowed_hosts_accepts_comma_separated_env() {
848 Jail::expect_with(|jail| {
850 jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com, www.example.com");
851 let s = Settings::from_env().unwrap();
852 assert_eq!(s.allowed_hosts, vec!["example.com", "www.example.com"]);
853 Ok(())
854 });
855 }
856
857 #[test]
858 fn allowed_hosts_accepts_single_env_value() {
859 Jail::expect_with(|jail| {
860 jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com");
861 let s = Settings::from_env().unwrap();
862 assert_eq!(s.allowed_hosts, vec!["example.com"]);
863 Ok(())
864 });
865 }
866
867 #[test]
868 fn allowed_hosts_accepts_bracketed_env_and_toml_array() {
869 Jail::expect_with(|jail| {
870 jail.set_env("UMBRAL_ALLOWED_HOSTS", r#"["a.com","b.com"]"#);
871 assert_eq!(
872 Settings::from_env().unwrap().allowed_hosts,
873 vec!["a.com", "b.com"]
874 );
875 Ok(())
876 });
877 Jail::expect_with(|jail| {
878 jail.create_file("umbral.toml", r#"allowed_hosts = ["a.com", "b.com"]"#)?;
879 assert_eq!(
880 Settings::from_env().unwrap().allowed_hosts,
881 vec!["a.com", "b.com"]
882 );
883 Ok(())
884 });
885 }
886
887 #[test]
888 fn umbral_env_var_overrides_database_url() {
889 Jail::expect_with(|jail| {
890 jail.set_env("UMBRAL_DATABASE_URL", "postgres://example");
891 let s = Settings::from_env().unwrap();
892 assert_eq!(s.database_url, "postgres://example");
893 Ok(())
894 });
895 }
896
897 #[test]
898 fn nested_env_var_populates_databases_map() {
899 Jail::expect_with(|jail| {
900 jail.set_env("UMBRAL_DATABASES__REPLICA", "sqlite://replica.db");
901 let s = Settings::from_env().unwrap();
902 assert_eq!(
903 s.databases.get("replica").map(String::as_str),
904 Some("sqlite://replica.db"),
905 );
906 Ok(())
907 });
908 }
909
910 #[test]
911 fn umbral_toml_in_cwd_is_loaded() {
912 Jail::expect_with(|jail| {
913 jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
914 let s = Settings::from_env().unwrap();
915 assert_eq!(s.secret_key, "from-toml");
916 Ok(())
917 });
918 }
919
920 #[test]
921 fn env_var_overrides_toml() {
922 Jail::expect_with(|jail| {
926 jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
927 jail.set_env("UMBRAL_SECRET_KEY", "from-env");
928 let s = Settings::from_env().unwrap();
929 assert_eq!(s.secret_key, "from-env");
930 Ok(())
931 });
932 }
933
934 #[test]
935 fn dotenv_file_overrides_toml() {
936 Jail::expect_with(|jail| {
937 jail.create_file("umbral.toml", r#"database_url = "sqlite://from-toml.db""#)?;
938 jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
939 let s = Settings::from_env().unwrap();
940 assert_eq!(s.database_url, "postgres://from-dotenv");
941 Ok(())
942 });
943 }
944
945 #[test]
946 fn dotenv_file_populates_nested_databases_map() {
947 Jail::expect_with(|jail| {
948 jail.create_file(".env", "UMBRAL_DATABASES__REPLICA=sqlite://replica.db\n")?;
949 let s = Settings::from_env().unwrap();
950 assert_eq!(
951 s.databases.get("replica").map(String::as_str),
952 Some("sqlite://replica.db"),
953 );
954 Ok(())
955 });
956 }
957
958 #[test]
959 fn process_env_overrides_dotenv_file() {
960 Jail::expect_with(|jail| {
961 jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
962 jail.set_env("UMBRAL_DATABASE_URL", "postgres://from-process-env");
963 let s = Settings::from_env().unwrap();
964 assert_eq!(s.database_url, "postgres://from-process-env");
965 Ok(())
966 });
967 }
968
969 #[test]
970 fn static_url_and_root_defaults() {
971 Jail::expect_with(|_| {
972 let s = Settings::from_env().unwrap();
973 assert_eq!(s.static_url, "/static/");
974 assert_eq!(s.static_root, "staticfiles/");
975 Ok(())
976 });
977 }
978
979 #[test]
980 fn static_url_env_override_is_normalised() {
981 Jail::expect_with(|jail| {
983 jail.set_env("UMBRAL_STATIC_URL", "/assets");
984 assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
985 Ok(())
986 });
987 Jail::expect_with(|jail| {
989 jail.set_env("UMBRAL_STATIC_URL", "assets");
990 assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
991 Ok(())
992 });
993 Jail::expect_with(|jail| {
995 jail.set_env("UMBRAL_STATIC_URL", "/assets/");
996 assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
997 Ok(())
998 });
999 }
1000
1001 #[test]
1002 fn static_url_normalises_three_input_shapes() {
1003 assert_eq!(normalize_static_url("/static"), "/static/");
1005 assert_eq!(normalize_static_url("static"), "/static/");
1006 assert_eq!(normalize_static_url("/static/"), "/static/");
1007 }
1008
1009 #[test]
1010 fn static_url_cdn_origin_keeps_scheme_and_host() {
1011 assert_eq!(
1014 normalize_static_url("https://cdn.example.com/s"),
1015 "https://cdn.example.com/s/"
1016 );
1017 assert_eq!(
1018 normalize_static_url("https://cdn.example.com/s/"),
1019 "https://cdn.example.com/s/"
1020 );
1021 }
1022
1023 #[test]
1024 fn static_root_env_override() {
1025 Jail::expect_with(|jail| {
1026 jail.set_env("UMBRAL_STATIC_ROOT", "build/assets/");
1027 assert_eq!(Settings::from_env().unwrap().static_root, "build/assets/");
1028 Ok(())
1029 });
1030 }
1031
1032 #[test]
1033 fn db_pool_defaults_apply_when_nothing_is_set() {
1034 Jail::expect_with(|_| {
1035 let s = Settings::from_env().unwrap();
1036 assert_eq!(s.db_max_connections, 10);
1037 assert_eq!(s.db_min_connections, 0);
1038 assert_eq!(s.db_acquire_timeout_secs, 30);
1039 assert_eq!(s.db_idle_timeout_secs, Some(600));
1040 assert_eq!(s.db_max_lifetime_secs, Some(1800));
1041 assert!(s.db_test_before_acquire);
1042 Ok(())
1043 });
1044 }
1045
1046 #[test]
1047 fn db_pool_env_overrides_each_knob() {
1048 Jail::expect_with(|jail| {
1049 jail.set_env("UMBRAL_DB_MAX_CONNECTIONS", "42");
1050 jail.set_env("UMBRAL_DB_MIN_CONNECTIONS", "4");
1051 jail.set_env("UMBRAL_DB_ACQUIRE_TIMEOUT_SECS", "7");
1052 jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "120");
1053 jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "240");
1054 jail.set_env("UMBRAL_DB_TEST_BEFORE_ACQUIRE", "false");
1055 let s = Settings::from_env().unwrap();
1056 assert_eq!(s.db_max_connections, 42);
1057 assert_eq!(s.db_min_connections, 4);
1058 assert_eq!(s.db_acquire_timeout_secs, 7);
1059 assert_eq!(s.db_idle_timeout_secs, Some(120));
1060 assert_eq!(s.db_max_lifetime_secs, Some(240));
1061 assert!(!s.db_test_before_acquire);
1062 Ok(())
1063 });
1064 }
1065
1066 #[test]
1067 fn db_timeout_zero_means_disabled_none() {
1068 Jail::expect_with(|jail| {
1069 jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "0");
1070 jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "0");
1071 let s = Settings::from_env().unwrap();
1072 assert_eq!(s.db_idle_timeout_secs, None);
1073 assert_eq!(s.db_max_lifetime_secs, None);
1074 Ok(())
1075 });
1076 }
1077
1078 #[test]
1079 fn db_timeout_empty_string_means_disabled_none() {
1080 Jail::expect_with(|jail| {
1081 jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "");
1082 let s = Settings::from_env().unwrap();
1083 assert_eq!(s.db_idle_timeout_secs, None);
1084 Ok(())
1085 });
1086 }
1087
1088 #[test]
1089 fn environment_default_is_profile_aware() {
1090 let d = Environment::default();
1094 if cfg!(debug_assertions) {
1095 assert!(
1096 matches!(d, Environment::Dev),
1097 "debug build must default to Dev"
1098 );
1099 } else {
1100 assert!(
1101 matches!(d, Environment::Prod),
1102 "release build must default to Prod (H14 secure-by-default)"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn environment_prod_round_trips_through_toml() {
1109 Jail::expect_with(|jail| {
1110 jail.create_file("umbral.toml", r#"environment = "Prod""#)?;
1111 let s = Settings::from_env().unwrap();
1112 assert!(matches!(s.environment, Environment::Prod));
1113 Ok(())
1114 });
1115 }
1116
1117 #[test]
1118 fn environment_is_case_insensitive() {
1119 for value in ["prod", "PROD", "Production", "production"] {
1122 Jail::expect_with(|jail| {
1123 jail.set_env("UMBRAL_ENVIRONMENT", value);
1124 let s = Settings::from_env().unwrap();
1125 assert!(
1126 matches!(s.environment, Environment::Prod),
1127 "`{value}` should deserialize to Prod",
1128 );
1129 Ok(())
1130 });
1131 }
1132 Jail::expect_with(|jail| {
1133 jail.set_env("UMBRAL_ENVIRONMENT", "test");
1134 assert!(matches!(
1135 Settings::from_env().unwrap().environment,
1136 Environment::Test
1137 ));
1138 Ok(())
1139 });
1140 }
1141
1142 #[test]
1143 fn environment_rejects_unknown_value() {
1144 Jail::expect_with(|jail| {
1145 jail.set_env("UMBRAL_ENVIRONMENT", "staging");
1146 assert!(
1147 Settings::from_env().is_err(),
1148 "an unknown environment must still be a load error"
1149 );
1150 Ok(())
1151 });
1152 }
1153
1154 #[test]
1157 fn debug_redacts_secrets() {
1158 let mut databases = std::collections::HashMap::new();
1159 databases.insert(
1160 "replica".to_string(),
1161 "postgres://ruser:rpass@replica.host/app".to_string(),
1162 );
1163 let mut extra = std::collections::HashMap::new();
1164 extra.insert(
1165 "stripe_secret".to_string(),
1166 toml::Value::String("sk_live_TOPSECRET".to_string()),
1167 );
1168 let settings = Settings {
1169 database_url: "postgres://alice:hunter2@db.host:5432/app".to_string(),
1170 databases,
1171 max_form_body_bytes: Some(1024),
1172 db_max_connections: 10,
1173 db_acquire_timeout_secs: 30,
1174 db_min_connections: 0,
1175 db_idle_timeout_secs: Some(600),
1176 db_max_lifetime_secs: Some(1800),
1177 db_test_before_acquire: true,
1178 secret_key: "SUPERSECRETKEYVALUE-do-not-leak".to_string(),
1179 environment: Environment::Prod,
1180 allowed_hosts: vec!["example.com".to_string()],
1181 log_level: "info".to_string(),
1182 bind_addr: "127.0.0.1:8000".to_string(),
1183 trusted_proxy_hops: 0,
1184 time_zone: None,
1185 static_url: "/static/".to_string(),
1186 static_root: "staticfiles/".to_string(),
1187 extra,
1188 };
1189 let rendered = format!("{settings:?}");
1190 assert!(
1191 !rendered.contains("SUPERSECRETKEYVALUE"),
1192 "secret_key leaked: {rendered}"
1193 );
1194 assert!(
1195 !rendered.contains("hunter2"),
1196 "database_url password leaked: {rendered}"
1197 );
1198 assert!(
1199 !rendered.contains("rpass"),
1200 "databases password leaked: {rendered}"
1201 );
1202 assert!(
1203 !rendered.contains("sk_live_TOPSECRET"),
1204 "extra value leaked: {rendered}"
1205 );
1206 assert!(
1208 rendered.contains("db.host"),
1209 "host should survive redaction"
1210 );
1211 assert!(
1212 rendered.contains("stripe_secret"),
1213 "extra keys stay visible to spot typos"
1214 );
1215 }
1216
1217 #[test]
1218 fn redact_url_userinfo_masks_password_keeps_host() {
1219 assert_eq!(
1220 redact_url_userinfo("postgres://alice:hunter2@db.host/app"),
1221 "postgres://***@db.host/app"
1222 );
1223 assert_eq!(redact_url_userinfo("sqlite::memory:"), "sqlite::memory:");
1225 assert_eq!(
1226 redact_url_userinfo("sqlite://data/app.db"),
1227 "sqlite://data/app.db"
1228 );
1229 }
1230
1231 #[test]
1236 fn unknown_env_var_is_captured_in_extra() {
1237 Jail::expect_with(|jail| {
1238 jail.set_env("UMBRAL_OPENAI_API_KEY", "sk-test-12345");
1239 let s = Settings::from_env().unwrap();
1240 assert_eq!(s.extra_str("openai_api_key"), Some("sk-test-12345"));
1241 assert_eq!(s.database_url, "sqlite::memory:");
1243 Ok(())
1244 });
1245 }
1246
1247 #[test]
1251 fn unknown_toml_table_is_captured_in_extra() {
1252 Jail::expect_with(|jail| {
1253 jail.create_file(
1254 "umbral.toml",
1255 r#"
1256 [external]
1257 provider = "stripe"
1258 "#,
1259 )?;
1260 let s = Settings::from_env().unwrap();
1261 let provider = s
1262 .extra
1263 .get("external")
1264 .and_then(|v| v.get("provider"))
1265 .and_then(|v| v.as_str());
1266 assert_eq!(provider, Some("stripe"));
1267 Ok(())
1268 });
1269 }
1270}