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#[derive(Clone, Debug, Deserialize)]
585pub enum Environment {
586    Dev,
587    Test,
588    Prod,
589}
590
591impl Default for Environment {
592    /// audit_2 H14 — secure by default. A **release** binary defaults to
593    /// `Prod` (Host validation on, the dev `SECRET_KEY` rejected at boot, prod
594    /// error pages) so a deploy that forgets to set `UMBRAL_ENVIRONMENT` is
595    /// locked down instead of silently serving with the dev protections off.
596    /// **Debug** builds (`cargo run`, `cargo test`) stay `Dev` for a
597    /// frictionless local loop. An explicit `UMBRAL_ENVIRONMENT` always wins —
598    /// this default only applies when the variable is unset (via
599    /// `#[serde(default)]` on `Settings.environment`).
600    fn default() -> Self {
601        if cfg!(debug_assertions) {
602            Environment::Dev
603        } else {
604            Environment::Prod
605        }
606    }
607}
608
609impl Settings {
610    /// Read a scalar string from the `extra` map by key. Returns
611    /// `None` if the key is absent or the value isn't a string.
612    ///
613    /// Most app-defined settings are scalar (`UMBRAL_OPENAI_API_KEY=
614    /// sk-test`), so this helper is the right shape for the common
615    /// case. For nested tables (`[external.openai]` in `umbral.toml`)
616    /// the caller indexes into `extra` directly: `settings.extra.
617    /// get("external").and_then(|v| v.get("openai")).and_then(...)`.
618    pub fn extra_str(&self, key: &str) -> Option<&str> {
619        self.extra.get(key).and_then(|v| v.as_str())
620    }
621
622    /// Load settings from defaults, `.env`, `umbral.toml`, and `UMBRAL_`-prefixed env vars.
623    ///
624    /// Precedence (later wins): struct defaults → `umbral.toml` → env vars. A
625    /// local `.env` file is merged as an environment-shaped provider first,
626    /// but existing process env vars keep precedence over values from `.env`.
627    /// Implementation uses `merge` (not `join`) for both providers so each
628    /// subsequent source overrides the previous one's values. With `join`
629    /// the first provider to set a key would keep it, which would invert
630    /// the documented precedence.
631    ///
632    /// The error type is boxed because `figment::Error` is large (over 200
633    /// bytes); see `clippy::result_large_err`.
634    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
644/// The flat `Settings` field names. A `UMBRAL_`-prefixed key that lands in the
645/// `extra` catch-all but is a near-miss of one of these is almost certainly a
646/// typo (`UMBRAL_ALOWED_HOSTS`, `UMBRAL_DB_MAX_CONNECTION`) rather than an
647/// app-defined value — warned at load (audit_2 core-app-config #16).
648const 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
668/// Classic iterative Levenshtein edit distance (two-row). Used only to catch
669/// misspelled framework settings keys at boot.
670fn 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
685/// Warn for each `extra` key that is within a small edit distance of a known
686/// framework settings key — a likely typo silently swallowed by the `extra`
687/// catch-all (audit_2 core-app-config #16). A genuine app-defined key that
688/// happens to be close is a harmless false positive (this only logs).
689fn 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)]
713// `Jail::expect_with` takes a closure returning `figment::Result<()>`, and
714// `figment::Error` is ~208 bytes. Boxing it here would only obscure tests
715// without any runtime benefit, so the lint is silenced module-wide.
716mod tests {
717    //! `Settings::init` and `settings::get` are intentionally out of scope here:
718    //! the process-wide `OnceLock` can be set exactly once per process, which
719    //! is incompatible with cargo test's parallel runner. Covering them
720    //! correctly needs `serial_test` or a thread-local refactor.
721    use super::*;
722
723    // audit_2 H9: the trusted-proxy client-IP resolver.
724    #[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        // hops=0: never trust the header, whatever it says.
736        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        // hops=1, one proxy: it appended the client's IP as the single (rightmost)
740        // entry → that's the client.
741        assert_eq!(
742            client_ip_with_hops(&hdrs(Some("203.0.113.7")), 1).as_deref(),
743            Some("203.0.113.7")
744        );
745        // hops=1 with a client-prepended spoof: the proxy still appended the real
746        // client to the right, so the spoof ("9.9.9.9") is IGNORED.
747        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        // hops=2: two trusted proxies appended the two rightmost entries; the real
753        // client is the 2nd from the right.
754        assert_eq!(
755            client_ip_with_hops(&hdrs(Some("9.9.9.9, real, proxy1")), 2).as_deref(),
756            Some("real")
757        );
758
759        // Chain shorter than `hops` (proxies didn't all append) → fail closed.
760        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    // audit_2 core-app-config #16: misspelled framework keys are caught as
765    // near-misses; genuine app-defined keys are not flagged.
766    #[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        // A genuine app-defined key is far from every framework key → not flagged.
778        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        // The natural comma-separated form: `UMBRAL_ALLOWED_HOSTS=a.com,b.com`.
806        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        // Matches the precedence documented on `Settings::from_env`:
880        // env vars override toml. The implementation uses `merge` (not
881        // `join`) precisely so this assertion holds.
882        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        // No trailing slash on input -> normalised to one.
939        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        // No leading slash either.
945        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        // Already-normalised value is left intact.
951        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        // The three canonical shapes from the spec all converge.
961        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        // An absolute CDN URL keeps its scheme+host and only gains a
969        // trailing slash — no spurious leading slash collapsing `https://`.
970        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        // audit_2 H14: debug builds default to Dev, release builds to Prod.
1048        // This test is correct in BOTH profiles (`cargo test` and
1049        // `cargo test --release`), so it pins the release branch too.
1050        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        // audit_2 #16: lowercase `prod` (the natural thing to type) used to
1077        // fail deserialization; now it resolves to Environment::Prod.
1078        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    /// audit_2 #11: the redacting `Debug` must never surface `secret_key`,
1112    /// the DB password in `database_url`/`databases`, or any `extra` value.
1113    #[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        // Non-secret context is still present + useful.
1164        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        // No userinfo → unchanged.
1181        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    /// An `UMBRAL_`-prefixed env var that doesn't correspond to a known
1189    /// `Settings` field falls into `extra` so user code can read it.
1190    /// `OPENAI_API_KEY` stands in for the common "I have an external
1191    /// service credential" case.
1192    #[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            // Known fields still resolve normally.
1199            assert_eq!(s.database_url, "sqlite::memory:");
1200            Ok(())
1201        });
1202    }
1203
1204    /// A nested `umbral.toml` table that doesn't map to a known field
1205    /// preserves its structure inside `extra`. The accessor walks the
1206    /// nested table directly via `toml::Value`.
1207    #[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}