Skip to main content

umbral_core/
settings.rs

1use figment::Figment;
2use figment::providers::{Env, Format, Serialized, Toml};
3use serde::Deserialize;
4use std::collections::HashSet;
5use std::sync::OnceLock;
6
7/// Ambient settings, published during `AppBuilder::build()`.
8pub(crate) static SETTINGS: OnceLock<Settings> = OnceLock::new();
9
10/// Initialize ambient settings. Called by `AppBuilder::build()` only.
11pub(crate) fn init(settings: &Settings) {
12    // Clone the settings into the OnceLock. The struct is cheap to clone
13    // (strings and vecs) and this avoids forcing the caller to surrender
14    // ownership of the original.
15    SETTINGS
16        .set(settings.clone())
17        .expect("umbral::settings::init called more than once");
18}
19
20/// Return a reference to the ambient settings.
21///
22/// # Panics
23///
24/// Panics if `App::build()` hasn't run.
25pub fn get() -> &'static Settings {
26    SETTINGS
27        .get()
28        .expect("umbral: settings not initialised — did you call App::build()?")
29}
30
31/// Return the ambient settings if they have been initialised, or `None`.
32///
33/// Unlike [`get`], this function never panics. Useful in plugin code
34/// that may run before `App::build()` (e.g. during tests or in
35/// route-builder helpers that check the environment at build time).
36pub fn get_opt() -> Option<&'static Settings> {
37    SETTINGS.get()
38}
39
40fn default_database_url() -> String {
41    // In-memory SQLite so first-run with all defaults works without any
42    // filesystem assumptions (a sqlite:// URL pointing at a non-existent
43    // file errors out without `?mode=rwc`). Real apps override this via
44    // umbral.toml or UMBRAL_DATABASE_URL.
45    "sqlite::memory:".into()
46}
47
48/// Default `Form<T>` body cap: 16 MiB — generous for urlencoded forms while
49/// still a DoS guard, and 8× the old hardcoded 2 MiB. Override via
50/// `UMBRAL_MAX_FORM_BODY_BYTES`, or set `0` to disable.
51fn 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
63/// Deserialize a `Vec<String>` from either a real sequence (a TOML array, or a
64/// bracketed env value like `["a.com","b.com"]`) OR a single comma-separated
65/// string (`UMBRAL_ALLOWED_HOSTS=a.com,b.com`). Env vars are scalar strings, so
66/// without this a list-valued setting can only be set with the non-obvious
67/// bracketed form; the natural `HOST1,HOST2` comma-separated form would error
68/// with "expected a sequence". Whitespace is trimmed and empty entries dropped.
69fn 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
95/// PERF-5: pool size default (matches sqlx's own default of 10). Raise
96/// for a high-concurrency Postgres deploy via `UMBRAL_DB_MAX_CONNECTIONS`.
97fn default_db_max_connections() -> u32 {
98    10
99}
100
101/// PERF-5: seconds to wait for a free pooled connection before failing a
102/// request. A bounded timeout means a saturated pool fails fast (503)
103/// instead of blocking the request task forever.
104fn default_db_acquire_timeout_secs() -> u64 {
105    30
106}
107
108/// gaps2 #91: idle-connection floor. `0` means "shrink to zero idle
109/// connections" — sqlx's own default. Raise it on a busy service to keep
110/// warm connections ready (saves the per-request TCP+TLS+auth handshake).
111fn default_db_min_connections() -> u32 {
112    0
113}
114
115/// gaps2 #91: close a connection that's been idle this many seconds.
116/// Default 10 minutes — reclaims connections during quiet periods so the
117/// pool doesn't pin `max_connections` slots on the server forever. `None`
118/// (env `0`/empty) disables idle reaping.
119fn default_db_idle_timeout_secs() -> Option<u64> {
120    Some(600)
121}
122
123/// gaps2 #91: recycle any connection older than this many seconds,
124/// regardless of activity. Default 30 minutes — defends against stale
125/// connections silently dropped by a load balancer or reaped by
126/// Postgres's `idle_in_transaction_session_timeout`. `None` (env
127/// `0`/empty) disables lifetime recycling.
128fn default_db_max_lifetime_secs() -> Option<u64> {
129    Some(1800)
130}
131
132/// gaps2 #91: health-check a pooled connection (a cheap `SELECT`/ping)
133/// before handing it to a caller. Default `true` — a dead connection
134/// (server restarted, network blip) is silently replaced instead of
135/// surfacing as a mid-request error. Set `false` to trade safety for a
136/// few microseconds per acquire on a known-stable network.
137fn default_db_test_before_acquire() -> bool {
138    true
139}
140
141fn default_trusted_proxy_hops() -> usize {
142    0
143}
144
145/// Derive the caller's IP from proxy headers under the ambient trusted-proxy
146/// policy (audit_2 H9). Returns `None` when no reliable client IP can be
147/// established — the caller (a throttle) then falls back to a scope that isn't
148/// client-forgeable rather than trusting a spoofable header.
149///
150/// - `trusted_proxy_hops == 0`: trust nothing. `X-Forwarded-For` is
151///   client-controlled with no proxy in front, so it's ignored → `None`.
152/// - `trusted_proxy_hops == n`: take the `(n+1)`-th `X-Forwarded-For` entry from
153///   the RIGHT (skipping the `n` entries your own proxies appended). A chain
154///   shorter than that is malformed/spoofed → `None` (fail closed).
155pub 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
160/// Pure core of [`client_ip`] — the `X-Forwarded-For` resolution for a given
161/// trusted-proxy hop count, independent of the ambient settings (so it's
162/// unit-testable). See [`client_ip`] for the policy.
163fn 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    // Each of our `hops` trusted proxies appended the address of its immediate
176    // upstream to the RIGHT of the chain (the nginx `$proxy_add_x_forwarded_for`
177    // convention). So the real client is the `hops`-th entry from the right —
178    // the address the OUTERMOST trusted proxy recorded. Anything further left
179    // was prepended by the client and is untrusted. A chain shorter than `hops`
180    // means the proxies didn't all append (spoofed / misconfigured) → None.
181    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 only by default — exposing the server on 0.0.0.0
190    // is a deliberate keystroke. Override with UMBRAL_BIND_ADDR or
191    // umbral.toml.
192    "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
203/// Normalise a `static_url` so it always carries exactly one leading
204/// and one trailing slash. `"/static"`, `"static"`, and `"/static/"`
205/// all converge on `"/static/"`. A CDN-style absolute URL
206/// (`"https://cdn.example.com/s"`) keeps its scheme+host and gains the
207/// trailing slash (`"https://cdn.example.com/s/"`) without acquiring a
208/// spurious leading slash. An empty value normalises to `"/"`.
209///
210/// The leading-slash rule only applies to root-relative paths; a value
211/// that already starts with `http://`, `https://`, or `//` is treated
212/// as absolute and left with its prefix intact.
213fn 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
232/// Deserialize and normalise `static_url` in one step so the invariant
233/// (leading + trailing slash) holds no matter the source — toml, env,
234/// or the struct default. Serde applies this to the raw string before
235/// it ever reaches a reader.
236fn 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
244/// Deserialize an `Option<u64>` where `0` (and an empty/missing string,
245/// as an env var might supply) maps to `None` — the "disabled" sentinel
246/// for the idle/max-lifetime timeouts (gaps2 #91). Accepts an integer
247/// (toml), a numeric string (env/dotenv), or an explicit null.
248fn 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
277/// Case-insensitive `Environment` deserialization (audit_2 core-app-config
278/// #16). The variants are `Dev` / `Test` / `Prod`, but every operator hint
279/// aside, `UMBRAL_ENVIRONMENT=prod` (lowercase — the natural thing to type)
280/// otherwise fails deserialization with a generic figment variant error.
281/// Accept any case plus the common long forms so a lowercase value boots the
282/// intended environment instead of erroring.
283fn 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    /// Max request-body size (bytes) the `Form<T>` extractor buffers before
346    /// returning `413 Payload Too Large`. Default **16 MiB** (8× the old
347    /// hardcoded 2 MiB). Set `UMBRAL_MAX_FORM_BODY_BYTES` (or `max_form_body_bytes`
348    /// in `umbral.toml`); set it to `0` to **disable** the cap entirely — handy
349    /// in dev. (For large uploads use a file field / the storage backend, not
350    /// the form extractor.)
351    #[serde(default = "default_max_form_body_bytes")]
352    pub max_form_body_bytes: Option<usize>,
353
354    /// Max connections in the Postgres pool (PERF-5). Default 10. Set via
355    /// `UMBRAL_DB_MAX_CONNECTIONS` or `db_max_connections` in `umbral.toml`.
356    #[serde(default = "default_db_max_connections")]
357    pub db_max_connections: u32,
358
359    /// Seconds to wait for a free pooled connection before failing the
360    /// request (Postgres acquire timeout, PERF-5). Default 30. Set via
361    /// `UMBRAL_DB_ACQUIRE_TIMEOUT_SECS` or `db_acquire_timeout_secs`.
362    #[serde(default = "default_db_acquire_timeout_secs")]
363    pub db_acquire_timeout_secs: u64,
364
365    /// Idle-connection floor — the pool keeps at least this many warm
366    /// connections (gaps2 #91). Default 0. Set via
367    /// `UMBRAL_DB_MIN_CONNECTIONS` or `db_min_connections`.
368    #[serde(default = "default_db_min_connections")]
369    pub db_min_connections: u32,
370
371    /// Close a connection after it's been idle this many seconds (gaps2
372    /// #91). Default 600 (10 min). `0`/empty disables idle reaping. Set
373    /// via `UMBRAL_DB_IDLE_TIMEOUT_SECS` or `db_idle_timeout_secs`.
374    #[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    /// Recycle a connection older than this many seconds (gaps2 #91).
381    /// Default 1800 (30 min) — avoids stale connections behind a load
382    /// balancer / Postgres idle-reaping. `0`/empty disables. Set via
383    /// `UMBRAL_DB_MAX_LIFETIME_SECS` or `db_max_lifetime_secs`.
384    #[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    /// Health-check a pooled connection before handing it out (gaps2
391    /// #91). Default true. Set via `UMBRAL_DB_TEST_BEFORE_ACQUIRE` or
392    /// `db_test_before_acquire`.
393    #[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    /// Number of trusted reverse-proxy hops in front of the app (audit_2 H9).
412    /// Governs how the framework derives a caller's IP for rate-limiting and
413    /// abuse controls from the `X-Forwarded-For` chain.
414    ///
415    /// **Default `0` — TRUST NOTHING.** With no proxy in front, `X-Forwarded-For`
416    /// is entirely client-controlled, so keying a throttle on it lets an
417    /// attacker rotate the header to dodge every limit. At `0` the framework
418    /// refuses to derive an IP from the header at all (throttles fall back to a
419    /// non-IP scope that can't be forged).
420    ///
421    /// Set it to the number of proxies YOU control that append to the header
422    /// (e.g. `1` behind a single nginx / cloud LB). The real client IP is then
423    /// taken as the `(hops+1)`-th entry from the RIGHT of the chain — the
424    /// entries your own proxies added are trusted; anything the client prepended
425    /// is ignored. Set `UMBRAL_TRUSTED_PROXY_HOPS` or `umbral.toml`.
426    #[serde(default = "default_trusted_proxy_hops")]
427    pub trusted_proxy_hops: usize,
428
429    /// The address the development server binds to.
430    /// `host:port` format, e.g. `127.0.0.1:8000` (default), `0.0.0.0:80`,
431    /// `[::1]:8000`. Override with `UMBRAL_BIND_ADDR` or `umbral.toml`.
432    #[serde(default = "default_bind_addr")]
433    pub bind_addr: String,
434
435    /// Gap 106 — timezone for marshalling naive datetimes on the
436    /// read and write boundary. `None` (default) keeps the
437    /// historical UTC-everywhere behaviour: naive input is treated
438    /// as UTC, admin-form display renders the stored UTC value
439    /// verbatim.
440    ///
441    /// `Some("Africa/Nairobi")` (any IANA tz name resolvable via
442    /// `chrono-tz`) flips both ends: HTML `<input type="datetime-
443    /// local">` values arriving naive are interpreted in the
444    /// configured tz then converted to UTC before storage; the
445    /// admin form renders stored UTC values converted back to the
446    /// configured tz so the user sees wall-clock time, not UTC.
447    /// Column type stays `TIMESTAMPTZ` (Postgres) / `TEXT`
448    /// (SQLite) — only the marshalling layer changes.
449    ///
450    /// Set via `UMBRAL_TIME_ZONE=Africa/Nairobi` or
451    /// `time_zone = "Africa/Nairobi"` in `umbral.toml`. An unknown
452    /// tz name falls back to UTC at lookup time with a tracing
453    /// warning rather than panicking — startup never fails on a
454    /// tz config error.
455    #[serde(default)]
456    pub time_zone: Option<String>,
457
458    /// URL prefix every collected/served static asset hangs under.
459    ///
460    /// Default `"/static/"`. The framework's static handler mounts at
461    /// this base and the `static()` template helper prepends it, so
462    /// `{{ static("admin/admin.css") }}` resolves to
463    /// `"/static/admin/admin.css"`. Set a CDN origin
464    /// (`UMBRAL_STATIC_URL=https://cdn.example.com/s/`) to serve assets
465    /// off a separate host in production — the helper then emits
466    /// absolute URLs and the local handler simply goes unused.
467    ///
468    /// Always normalised to carry exactly one leading and one trailing
469    /// slash: `"/static"`, `"static"`, and `"/static/"` all converge on
470    /// `"/static/"`. Set via `UMBRAL_STATIC_URL` or `static_url` in
471    /// `umbral.toml`.
472    #[serde(
473        default = "default_static_url",
474        deserialize_with = "deserialize_static_url"
475    )]
476    pub static_url: String,
477
478    /// On-disk directory collected static assets live under in
479    /// production.
480    ///
481    /// Default `"staticfiles/"` (relative to the binary's CWD). The
482    /// static handler resolves a request `/static/<ns>/<rest>` to
483    /// `<static_root>/<ns>/<rest>` in prod, and as the dev fallback
484    /// when a plugin's live source dir doesn't have the file. Set via
485    /// `UMBRAL_STATIC_ROOT` or `static_root` in `umbral.toml`.
486    #[serde(default = "default_static_root")]
487    pub static_root: String,
488
489    /// Catch-all for `UMBRAL_`-prefixed environment variables (and
490    /// `umbral.toml` keys) that don't map to a named field above.
491    ///
492    /// Real apps usually need keys the framework doesn't know about —
493    /// `OPENAI_API_KEY`, `STRIPE_SECRET`, third-party plugin
494    /// configuration. Setting `UMBRAL_OPENAI_API_KEY=sk-test` makes
495    /// `settings.extra.get("openai_api_key")` return a string value
496    /// without the user crate having to wire a second figment loader.
497    ///
498    /// Values are stored as `toml::Value` so a nested
499    /// `[external.openai]` table in `umbral.toml` round-trips with its
500    /// structure intact. The accessor [`Settings::extra_str`] handles
501    /// the common scalar-string case.
502    #[serde(flatten)]
503    pub extra: std::collections::HashMap<String, toml::Value>,
504}
505
506/// Redact the userinfo (`user:password`) of a connection URL, keeping the
507/// scheme and host so the value stays diagnosable without leaking the
508/// password. `postgres://alice:s3cret@db.host/app` →
509/// `postgres://***@db.host/app`. A URL with no `@` (e.g. `sqlite::memory:`)
510/// is returned unchanged.
511fn 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    // Only treat an `@` in the authority section (before the first `/`,
517    // `?`, or `#`) as a userinfo delimiter.
518    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
528/// Newtype so the redacting `Debug` for [`Settings`] can print the
529/// `databases` map with each URL's userinfo masked.
530struct 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
540/// Newtype so the redacting `Debug` for [`Settings`] can print the `extra`
541/// map's keys (useful for spotting a typo'd setting) while masking every
542/// value — `extra` is where arbitrary third-party API keys land.
543struct 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
553/// Hand-written, redacting `Debug` (audit_2 core-app-config #11). The derived
554/// `Debug` printed `secret_key`, the DB password inside `database_url` /
555/// `databases`, and every `extra` value in plaintext — one `tracing::debug!
556/// (?settings)` or `?ctx` (which embeds `Settings`) away from leaking every
557/// credential the app holds. This impl masks the three secret-bearing fields
558/// and prints the rest verbatim so the value stays useful for debugging.
559impl 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/// `PartialEq` / `Eq` / `Copy` are not decoration (gaps3 #64). The `startproject`
585/// scaffold generates `if settings.environment != Environment::Prod { ... }` in its seed
586/// step — the single most obvious thing anyone does with this enum — and without
587/// `PartialEq` that line does not compile. A scaffolded project has never built.
588#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
589pub enum Environment {
590    Dev,
591    Test,
592    Prod,
593}
594
595impl Default for Environment {
596    /// audit_2 H14 — secure by default. A **release** binary defaults to
597    /// `Prod` (Host validation on, the dev `SECRET_KEY` rejected at boot, prod
598    /// error pages) so a deploy that forgets to set `UMBRAL_ENVIRONMENT` is
599    /// locked down instead of silently serving with the dev protections off.
600    /// **Debug** builds (`cargo run`, `cargo test`) stay `Dev` for a
601    /// frictionless local loop. An explicit `UMBRAL_ENVIRONMENT` always wins —
602    /// this default only applies when the variable is unset (via
603    /// `#[serde(default)]` on `Settings.environment`).
604    fn default() -> Self {
605        if cfg!(debug_assertions) {
606            Environment::Dev
607        } else {
608            Environment::Prod
609        }
610    }
611}
612
613impl Settings {
614    /// Read a scalar string from the `extra` map by key. Returns
615    /// `None` if the key is absent or the value isn't a string.
616    ///
617    /// Most app-defined settings are scalar (`UMBRAL_OPENAI_API_KEY=
618    /// sk-test`), so this helper is the right shape for the common
619    /// case. For nested tables (`[external.openai]` in `umbral.toml`)
620    /// the caller indexes into `extra` directly: `settings.extra.
621    /// get("external").and_then(|v| v.get("openai")).and_then(...)`.
622    pub fn extra_str(&self, key: &str) -> Option<&str> {
623        self.extra.get(key).and_then(|v| v.as_str())
624    }
625
626    /// Load settings from defaults, `.env`, `umbral.toml`, and `UMBRAL_`-prefixed env vars.
627    ///
628    /// Precedence (later wins): struct defaults → `umbral.toml` → env vars. A
629    /// local `.env` file is merged as an environment-shaped provider first,
630    /// but existing process env vars keep precedence over values from `.env`.
631    /// Implementation uses `merge` (not `join`) for both providers so each
632    /// subsequent source overrides the previous one's values. With `join`
633    /// the first provider to set a key would keep it, which would invert
634    /// the documented precedence.
635    ///
636    /// The error type is boxed because `figment::Error` is large (over 200
637    /// bytes); see `clippy::result_large_err`.
638    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
649/// gaps3 #61 — shout about `UMBRA_*` environment variables, which are IGNORED.
650///
651/// The framework was renamed `umbra` → `umbral`, and the settings prefix moved with it.
652/// A leftover `UMBRA_DATABASE_URL` is not a near-miss key that figment reports as
653/// unmapped — it never reaches figment at all, because the prefix does not match. It is
654/// *invisible*.
655///
656/// That is as bad as it sounds. An app whose `.env` still says `UMBRA_DATABASE_URL` falls
657/// back to the DEFAULT database url, which is `sqlite::memory:` — so it runs happily
658/// against a database that evaporates on exit, and `migrate` reports "Applied N
659/// migration(s)" having written to nothing. Found the hard way in `examples/shop`, whose
660/// entire `.env` had been dead since the rename.
661///
662/// Warn, not fail: an app may legitimately have `UMBRA_`-prefixed variables belonging to
663/// something else entirely. But say it loudly, name the variables, and name the fix.
664fn 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
687/// The flat `Settings` field names. A `UMBRAL_`-prefixed key that lands in the
688/// `extra` catch-all but is a near-miss of one of these is almost certainly a
689/// typo (`UMBRAL_ALOWED_HOSTS`, `UMBRAL_DB_MAX_CONNECTION`) rather than an
690/// app-defined value — warned at load (audit_2 core-app-config #16).
691const 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
711/// Classic iterative Levenshtein edit distance (two-row). Used only to catch
712/// misspelled framework settings keys at boot.
713fn 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
728/// Warn for each `extra` key that is within a small edit distance of a known
729/// framework settings key — a likely typo silently swallowed by the `extra`
730/// catch-all (audit_2 core-app-config #16). A genuine app-defined key that
731/// happens to be close is a harmless false positive (this only logs).
732fn 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)]
756// `Jail::expect_with` takes a closure returning `figment::Result<()>`, and
757// `figment::Error` is ~208 bytes. Boxing it here would only obscure tests
758// without any runtime benefit, so the lint is silenced module-wide.
759mod tests {
760    //! `Settings::init` and `settings::get` are intentionally out of scope here:
761    //! the process-wide `OnceLock` can be set exactly once per process, which
762    //! is incompatible with cargo test's parallel runner. Covering them
763    //! correctly needs `serial_test` or a thread-local refactor.
764    use super::*;
765
766    // audit_2 H9: the trusted-proxy client-IP resolver.
767    #[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        // hops=0: never trust the header, whatever it says.
779        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        // hops=1, one proxy: it appended the client's IP as the single (rightmost)
783        // entry → that's the client.
784        assert_eq!(
785            client_ip_with_hops(&hdrs(Some("203.0.113.7")), 1).as_deref(),
786            Some("203.0.113.7")
787        );
788        // hops=1 with a client-prepended spoof: the proxy still appended the real
789        // client to the right, so the spoof ("9.9.9.9") is IGNORED.
790        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        // hops=2: two trusted proxies appended the two rightmost entries; the real
796        // client is the 2nd from the right.
797        assert_eq!(
798            client_ip_with_hops(&hdrs(Some("9.9.9.9, real, proxy1")), 2).as_deref(),
799            Some("real")
800        );
801
802        // Chain shorter than `hops` (proxies didn't all append) → fail closed.
803        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    // audit_2 core-app-config #16: misspelled framework keys are caught as
808    // near-misses; genuine app-defined keys are not flagged.
809    #[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        // A genuine app-defined key is far from every framework key → not flagged.
821        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        // The natural comma-separated form: `UMBRAL_ALLOWED_HOSTS=a.com,b.com`.
849        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        // Matches the precedence documented on `Settings::from_env`:
923        // env vars override toml. The implementation uses `merge` (not
924        // `join`) precisely so this assertion holds.
925        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        // No trailing slash on input -> normalised to one.
982        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        // No leading slash either.
988        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        // Already-normalised value is left intact.
994        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        // The three canonical shapes from the spec all converge.
1004        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        // An absolute CDN URL keeps its scheme+host and only gains a
1012        // trailing slash — no spurious leading slash collapsing `https://`.
1013        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        // audit_2 H14: debug builds default to Dev, release builds to Prod.
1091        // This test is correct in BOTH profiles (`cargo test` and
1092        // `cargo test --release`), so it pins the release branch too.
1093        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        // audit_2 #16: lowercase `prod` (the natural thing to type) used to
1120        // fail deserialization; now it resolves to Environment::Prod.
1121        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    /// audit_2 #11: the redacting `Debug` must never surface `secret_key`,
1155    /// the DB password in `database_url`/`databases`, or any `extra` value.
1156    #[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        // Non-secret context is still present + useful.
1207        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        // No userinfo → unchanged.
1224        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    /// An `UMBRAL_`-prefixed env var that doesn't correspond to a known
1232    /// `Settings` field falls into `extra` so user code can read it.
1233    /// `OPENAI_API_KEY` stands in for the common "I have an external
1234    /// service credential" case.
1235    #[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            // Known fields still resolve normally.
1242            assert_eq!(s.database_url, "sqlite::memory:");
1243            Ok(())
1244        });
1245    }
1246
1247    /// A nested `umbral.toml` table that doesn't map to a known field
1248    /// preserves its structure inside `extra`. The accessor walks the
1249    /// nested table directly via `toml::Value`.
1250    #[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}