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, Debug, Deserialize)]
585pub enum Environment {
586 Dev,
587 Test,
588 Prod,
589}
590
591impl Default for Environment {
592 fn default() -> Self {
601 if cfg!(debug_assertions) {
602 Environment::Dev
603 } else {
604 Environment::Prod
605 }
606 }
607}
608
609impl Settings {
610 pub fn extra_str(&self, key: &str) -> Option<&str> {
619 self.extra.get(key).and_then(|v| v.as_str())
620 }
621
622 pub fn from_env() -> Result<Self, Box<figment::Error>> {
635 let settings: Settings = merge_dotenv(Figment::new().merge(Toml::file("umbral.toml")))
636 .merge(Env::prefixed("UMBRAL_").split("__"))
637 .extract()
638 .map_err(Box::new)?;
639 warn_on_near_miss_keys(&settings.extra);
640 Ok(settings)
641 }
642}
643
644const KNOWN_SETTINGS_KEYS: &[&str] = &[
649 "database_url",
650 "databases",
651 "max_form_body_bytes",
652 "db_max_connections",
653 "db_acquire_timeout_secs",
654 "db_min_connections",
655 "db_idle_timeout_secs",
656 "db_max_lifetime_secs",
657 "db_test_before_acquire",
658 "secret_key",
659 "environment",
660 "allowed_hosts",
661 "log_level",
662 "bind_addr",
663 "time_zone",
664 "static_url",
665 "static_root",
666];
667
668fn levenshtein(a: &str, b: &str) -> usize {
671 let (a, b) = (a.as_bytes(), b.as_bytes());
672 let mut prev: Vec<usize> = (0..=b.len()).collect();
673 let mut curr: Vec<usize> = vec![0; b.len() + 1];
674 for (i, &ca) in a.iter().enumerate() {
675 curr[0] = i + 1;
676 for (j, &cb) in b.iter().enumerate() {
677 let cost = usize::from(ca != cb);
678 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
679 }
680 std::mem::swap(&mut prev, &mut curr);
681 }
682 prev[b.len()]
683}
684
685fn warn_on_near_miss_keys(extra: &std::collections::HashMap<String, toml::Value>) {
690 for key in extra.keys() {
691 let key_l = key.to_ascii_lowercase();
692 if let Some((known, dist)) = KNOWN_SETTINGS_KEYS
693 .iter()
694 .map(|k| (*k, levenshtein(&key_l, k)))
695 .min_by_key(|(_, d)| *d)
696 && (1..=2).contains(&dist)
697 {
698 tracing::warn!(
699 key = %key,
700 did_you_mean = %known,
701 "settings: `UMBRAL_{}` is not a known framework key but is very close to \
702 `UMBRAL_{}` — did you mean that? It was accepted as an app-defined value \
703 in `extra` and will NOT configure the framework.",
704 key_l.to_ascii_uppercase(),
705 known.to_ascii_uppercase(),
706 );
707 }
708 }
709}
710
711#[cfg(test)]
712#[allow(clippy::result_large_err)]
713mod tests {
717 use super::*;
722
723 #[test]
725 fn client_ip_honors_trusted_proxy_hops() {
726 use super::client_ip_with_hops;
727 fn hdrs(xff: Option<&str>) -> crate::web::HeaderMap {
728 let mut h = crate::web::HeaderMap::new();
729 if let Some(v) = xff {
730 h.insert("x-forwarded-for", v.parse().unwrap());
731 }
732 h
733 }
734
735 assert_eq!(client_ip_with_hops(&hdrs(Some("1.2.3.4")), 0), None);
737 assert_eq!(client_ip_with_hops(&hdrs(None), 0), None);
738
739 assert_eq!(
742 client_ip_with_hops(&hdrs(Some("203.0.113.7")), 1).as_deref(),
743 Some("203.0.113.7")
744 );
745 assert_eq!(
748 client_ip_with_hops(&hdrs(Some("9.9.9.9, 203.0.113.7")), 1).as_deref(),
749 Some("203.0.113.7")
750 );
751
752 assert_eq!(
755 client_ip_with_hops(&hdrs(Some("9.9.9.9, real, proxy1")), 2).as_deref(),
756 Some("real")
757 );
758
759 assert_eq!(client_ip_with_hops(&hdrs(Some("only-one")), 2), None);
761 assert_eq!(client_ip_with_hops(&hdrs(None), 1), None);
762 }
763
764 #[test]
767 fn misspelled_framework_keys_are_near_misses() {
768 for (typo, target) in [
769 ("alowed_hosts", "allowed_hosts"),
770 ("db_max_connection", "db_max_connections"),
771 ("secret_ky", "secret_key"),
772 ("enviroment", "environment"),
773 ] {
774 let d = levenshtein(typo, target);
775 assert!((1..=2).contains(&d), "`{typo}` vs `{target}`: distance {d}");
776 }
777 for app_key in ["openai_api_key", "stripe_secret", "sentry_dsn"] {
779 let min = KNOWN_SETTINGS_KEYS
780 .iter()
781 .map(|k| levenshtein(app_key, k))
782 .min()
783 .unwrap();
784 assert!(min > 2, "`{app_key}` should not be a near-miss (min {min})");
785 }
786 }
787 use figment::Jail;
788
789 #[test]
790 fn defaults_apply_when_nothing_is_set() {
791 Jail::expect_with(|_| {
792 let s = Settings::from_env().unwrap();
793 assert_eq!(s.database_url, "sqlite::memory:");
794 assert_eq!(s.secret_key, "umbral-insecure-dev-key-change-me");
795 assert_eq!(s.allowed_hosts, vec!["localhost", "127.0.0.1"]);
796 assert_eq!(s.log_level, "info");
797 assert!(matches!(s.environment, Environment::Dev));
798 assert!(s.databases.is_empty());
799 Ok(())
800 });
801 }
802
803 #[test]
804 fn allowed_hosts_accepts_comma_separated_env() {
805 Jail::expect_with(|jail| {
807 jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com, www.example.com");
808 let s = Settings::from_env().unwrap();
809 assert_eq!(s.allowed_hosts, vec!["example.com", "www.example.com"]);
810 Ok(())
811 });
812 }
813
814 #[test]
815 fn allowed_hosts_accepts_single_env_value() {
816 Jail::expect_with(|jail| {
817 jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com");
818 let s = Settings::from_env().unwrap();
819 assert_eq!(s.allowed_hosts, vec!["example.com"]);
820 Ok(())
821 });
822 }
823
824 #[test]
825 fn allowed_hosts_accepts_bracketed_env_and_toml_array() {
826 Jail::expect_with(|jail| {
827 jail.set_env("UMBRAL_ALLOWED_HOSTS", r#"["a.com","b.com"]"#);
828 assert_eq!(
829 Settings::from_env().unwrap().allowed_hosts,
830 vec!["a.com", "b.com"]
831 );
832 Ok(())
833 });
834 Jail::expect_with(|jail| {
835 jail.create_file("umbral.toml", r#"allowed_hosts = ["a.com", "b.com"]"#)?;
836 assert_eq!(
837 Settings::from_env().unwrap().allowed_hosts,
838 vec!["a.com", "b.com"]
839 );
840 Ok(())
841 });
842 }
843
844 #[test]
845 fn umbral_env_var_overrides_database_url() {
846 Jail::expect_with(|jail| {
847 jail.set_env("UMBRAL_DATABASE_URL", "postgres://example");
848 let s = Settings::from_env().unwrap();
849 assert_eq!(s.database_url, "postgres://example");
850 Ok(())
851 });
852 }
853
854 #[test]
855 fn nested_env_var_populates_databases_map() {
856 Jail::expect_with(|jail| {
857 jail.set_env("UMBRAL_DATABASES__REPLICA", "sqlite://replica.db");
858 let s = Settings::from_env().unwrap();
859 assert_eq!(
860 s.databases.get("replica").map(String::as_str),
861 Some("sqlite://replica.db"),
862 );
863 Ok(())
864 });
865 }
866
867 #[test]
868 fn umbral_toml_in_cwd_is_loaded() {
869 Jail::expect_with(|jail| {
870 jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
871 let s = Settings::from_env().unwrap();
872 assert_eq!(s.secret_key, "from-toml");
873 Ok(())
874 });
875 }
876
877 #[test]
878 fn env_var_overrides_toml() {
879 Jail::expect_with(|jail| {
883 jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
884 jail.set_env("UMBRAL_SECRET_KEY", "from-env");
885 let s = Settings::from_env().unwrap();
886 assert_eq!(s.secret_key, "from-env");
887 Ok(())
888 });
889 }
890
891 #[test]
892 fn dotenv_file_overrides_toml() {
893 Jail::expect_with(|jail| {
894 jail.create_file("umbral.toml", r#"database_url = "sqlite://from-toml.db""#)?;
895 jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
896 let s = Settings::from_env().unwrap();
897 assert_eq!(s.database_url, "postgres://from-dotenv");
898 Ok(())
899 });
900 }
901
902 #[test]
903 fn dotenv_file_populates_nested_databases_map() {
904 Jail::expect_with(|jail| {
905 jail.create_file(".env", "UMBRAL_DATABASES__REPLICA=sqlite://replica.db\n")?;
906 let s = Settings::from_env().unwrap();
907 assert_eq!(
908 s.databases.get("replica").map(String::as_str),
909 Some("sqlite://replica.db"),
910 );
911 Ok(())
912 });
913 }
914
915 #[test]
916 fn process_env_overrides_dotenv_file() {
917 Jail::expect_with(|jail| {
918 jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
919 jail.set_env("UMBRAL_DATABASE_URL", "postgres://from-process-env");
920 let s = Settings::from_env().unwrap();
921 assert_eq!(s.database_url, "postgres://from-process-env");
922 Ok(())
923 });
924 }
925
926 #[test]
927 fn static_url_and_root_defaults() {
928 Jail::expect_with(|_| {
929 let s = Settings::from_env().unwrap();
930 assert_eq!(s.static_url, "/static/");
931 assert_eq!(s.static_root, "staticfiles/");
932 Ok(())
933 });
934 }
935
936 #[test]
937 fn static_url_env_override_is_normalised() {
938 Jail::expect_with(|jail| {
940 jail.set_env("UMBRAL_STATIC_URL", "/assets");
941 assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
942 Ok(())
943 });
944 Jail::expect_with(|jail| {
946 jail.set_env("UMBRAL_STATIC_URL", "assets");
947 assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
948 Ok(())
949 });
950 Jail::expect_with(|jail| {
952 jail.set_env("UMBRAL_STATIC_URL", "/assets/");
953 assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
954 Ok(())
955 });
956 }
957
958 #[test]
959 fn static_url_normalises_three_input_shapes() {
960 assert_eq!(normalize_static_url("/static"), "/static/");
962 assert_eq!(normalize_static_url("static"), "/static/");
963 assert_eq!(normalize_static_url("/static/"), "/static/");
964 }
965
966 #[test]
967 fn static_url_cdn_origin_keeps_scheme_and_host() {
968 assert_eq!(
971 normalize_static_url("https://cdn.example.com/s"),
972 "https://cdn.example.com/s/"
973 );
974 assert_eq!(
975 normalize_static_url("https://cdn.example.com/s/"),
976 "https://cdn.example.com/s/"
977 );
978 }
979
980 #[test]
981 fn static_root_env_override() {
982 Jail::expect_with(|jail| {
983 jail.set_env("UMBRAL_STATIC_ROOT", "build/assets/");
984 assert_eq!(Settings::from_env().unwrap().static_root, "build/assets/");
985 Ok(())
986 });
987 }
988
989 #[test]
990 fn db_pool_defaults_apply_when_nothing_is_set() {
991 Jail::expect_with(|_| {
992 let s = Settings::from_env().unwrap();
993 assert_eq!(s.db_max_connections, 10);
994 assert_eq!(s.db_min_connections, 0);
995 assert_eq!(s.db_acquire_timeout_secs, 30);
996 assert_eq!(s.db_idle_timeout_secs, Some(600));
997 assert_eq!(s.db_max_lifetime_secs, Some(1800));
998 assert!(s.db_test_before_acquire);
999 Ok(())
1000 });
1001 }
1002
1003 #[test]
1004 fn db_pool_env_overrides_each_knob() {
1005 Jail::expect_with(|jail| {
1006 jail.set_env("UMBRAL_DB_MAX_CONNECTIONS", "42");
1007 jail.set_env("UMBRAL_DB_MIN_CONNECTIONS", "4");
1008 jail.set_env("UMBRAL_DB_ACQUIRE_TIMEOUT_SECS", "7");
1009 jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "120");
1010 jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "240");
1011 jail.set_env("UMBRAL_DB_TEST_BEFORE_ACQUIRE", "false");
1012 let s = Settings::from_env().unwrap();
1013 assert_eq!(s.db_max_connections, 42);
1014 assert_eq!(s.db_min_connections, 4);
1015 assert_eq!(s.db_acquire_timeout_secs, 7);
1016 assert_eq!(s.db_idle_timeout_secs, Some(120));
1017 assert_eq!(s.db_max_lifetime_secs, Some(240));
1018 assert!(!s.db_test_before_acquire);
1019 Ok(())
1020 });
1021 }
1022
1023 #[test]
1024 fn db_timeout_zero_means_disabled_none() {
1025 Jail::expect_with(|jail| {
1026 jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "0");
1027 jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "0");
1028 let s = Settings::from_env().unwrap();
1029 assert_eq!(s.db_idle_timeout_secs, None);
1030 assert_eq!(s.db_max_lifetime_secs, None);
1031 Ok(())
1032 });
1033 }
1034
1035 #[test]
1036 fn db_timeout_empty_string_means_disabled_none() {
1037 Jail::expect_with(|jail| {
1038 jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "");
1039 let s = Settings::from_env().unwrap();
1040 assert_eq!(s.db_idle_timeout_secs, None);
1041 Ok(())
1042 });
1043 }
1044
1045 #[test]
1046 fn environment_default_is_profile_aware() {
1047 let d = Environment::default();
1051 if cfg!(debug_assertions) {
1052 assert!(
1053 matches!(d, Environment::Dev),
1054 "debug build must default to Dev"
1055 );
1056 } else {
1057 assert!(
1058 matches!(d, Environment::Prod),
1059 "release build must default to Prod (H14 secure-by-default)"
1060 );
1061 }
1062 }
1063
1064 #[test]
1065 fn environment_prod_round_trips_through_toml() {
1066 Jail::expect_with(|jail| {
1067 jail.create_file("umbral.toml", r#"environment = "Prod""#)?;
1068 let s = Settings::from_env().unwrap();
1069 assert!(matches!(s.environment, Environment::Prod));
1070 Ok(())
1071 });
1072 }
1073
1074 #[test]
1075 fn environment_is_case_insensitive() {
1076 for value in ["prod", "PROD", "Production", "production"] {
1079 Jail::expect_with(|jail| {
1080 jail.set_env("UMBRAL_ENVIRONMENT", value);
1081 let s = Settings::from_env().unwrap();
1082 assert!(
1083 matches!(s.environment, Environment::Prod),
1084 "`{value}` should deserialize to Prod",
1085 );
1086 Ok(())
1087 });
1088 }
1089 Jail::expect_with(|jail| {
1090 jail.set_env("UMBRAL_ENVIRONMENT", "test");
1091 assert!(matches!(
1092 Settings::from_env().unwrap().environment,
1093 Environment::Test
1094 ));
1095 Ok(())
1096 });
1097 }
1098
1099 #[test]
1100 fn environment_rejects_unknown_value() {
1101 Jail::expect_with(|jail| {
1102 jail.set_env("UMBRAL_ENVIRONMENT", "staging");
1103 assert!(
1104 Settings::from_env().is_err(),
1105 "an unknown environment must still be a load error"
1106 );
1107 Ok(())
1108 });
1109 }
1110
1111 #[test]
1114 fn debug_redacts_secrets() {
1115 let mut databases = std::collections::HashMap::new();
1116 databases.insert(
1117 "replica".to_string(),
1118 "postgres://ruser:rpass@replica.host/app".to_string(),
1119 );
1120 let mut extra = std::collections::HashMap::new();
1121 extra.insert(
1122 "stripe_secret".to_string(),
1123 toml::Value::String("sk_live_TOPSECRET".to_string()),
1124 );
1125 let settings = Settings {
1126 database_url: "postgres://alice:hunter2@db.host:5432/app".to_string(),
1127 databases,
1128 max_form_body_bytes: Some(1024),
1129 db_max_connections: 10,
1130 db_acquire_timeout_secs: 30,
1131 db_min_connections: 0,
1132 db_idle_timeout_secs: Some(600),
1133 db_max_lifetime_secs: Some(1800),
1134 db_test_before_acquire: true,
1135 secret_key: "SUPERSECRETKEYVALUE-do-not-leak".to_string(),
1136 environment: Environment::Prod,
1137 allowed_hosts: vec!["example.com".to_string()],
1138 log_level: "info".to_string(),
1139 bind_addr: "127.0.0.1:8000".to_string(),
1140 trusted_proxy_hops: 0,
1141 time_zone: None,
1142 static_url: "/static/".to_string(),
1143 static_root: "staticfiles/".to_string(),
1144 extra,
1145 };
1146 let rendered = format!("{settings:?}");
1147 assert!(
1148 !rendered.contains("SUPERSECRETKEYVALUE"),
1149 "secret_key leaked: {rendered}"
1150 );
1151 assert!(
1152 !rendered.contains("hunter2"),
1153 "database_url password leaked: {rendered}"
1154 );
1155 assert!(
1156 !rendered.contains("rpass"),
1157 "databases password leaked: {rendered}"
1158 );
1159 assert!(
1160 !rendered.contains("sk_live_TOPSECRET"),
1161 "extra value leaked: {rendered}"
1162 );
1163 assert!(
1165 rendered.contains("db.host"),
1166 "host should survive redaction"
1167 );
1168 assert!(
1169 rendered.contains("stripe_secret"),
1170 "extra keys stay visible to spot typos"
1171 );
1172 }
1173
1174 #[test]
1175 fn redact_url_userinfo_masks_password_keeps_host() {
1176 assert_eq!(
1177 redact_url_userinfo("postgres://alice:hunter2@db.host/app"),
1178 "postgres://***@db.host/app"
1179 );
1180 assert_eq!(redact_url_userinfo("sqlite::memory:"), "sqlite::memory:");
1182 assert_eq!(
1183 redact_url_userinfo("sqlite://data/app.db"),
1184 "sqlite://data/app.db"
1185 );
1186 }
1187
1188 #[test]
1193 fn unknown_env_var_is_captured_in_extra() {
1194 Jail::expect_with(|jail| {
1195 jail.set_env("UMBRAL_OPENAI_API_KEY", "sk-test-12345");
1196 let s = Settings::from_env().unwrap();
1197 assert_eq!(s.extra_str("openai_api_key"), Some("sk-test-12345"));
1198 assert_eq!(s.database_url, "sqlite::memory:");
1200 Ok(())
1201 });
1202 }
1203
1204 #[test]
1208 fn unknown_toml_table_is_captured_in_extra() {
1209 Jail::expect_with(|jail| {
1210 jail.create_file(
1211 "umbral.toml",
1212 r#"
1213 [external]
1214 provider = "stripe"
1215 "#,
1216 )?;
1217 let s = Settings::from_env().unwrap();
1218 let provider = s
1219 .extra
1220 .get("external")
1221 .and_then(|v| v.get("provider"))
1222 .and_then(|v| v.as_str());
1223 assert_eq!(provider, Some("stripe"));
1224 Ok(())
1225 });
1226 }
1227}