Skip to main content

renox_core/
config.rs

1use std::env;
2use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3use std::path::PathBuf;
4use std::time::Duration;
5
6use anyhow::{Context, bail};
7
8use crate::mail::MailConfig;
9use crate::storage::StorageConfig;
10
11/// The environment the application runs in, from `APP_ENV`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum Environment {
15    /// `local` (also `dev`, `development`), the default.
16    Local,
17    /// `testing` (also `test`).
18    Testing,
19    /// `production` (also `prod`); requires `APP_KEY`.
20    Production,
21}
22
23impl Environment {
24    fn parse(value: &str) -> anyhow::Result<Self> {
25        match value.to_ascii_lowercase().as_str() {
26            "local" | "dev" | "development" => Ok(Self::Local),
27            "testing" | "test" => Ok(Self::Testing),
28            "production" | "prod" => Ok(Self::Production),
29            other => bail!("APP_ENV must be local, testing or production, got `{other}`"),
30        }
31    }
32}
33
34/// How strict the Content-Security-Policy header is, from `CSP`.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[non_exhaustive]
37pub enum CspMode {
38    /// Scripts from this site, inline scripts and `eval` (which Alpine.js's
39    /// standard build needs) are allowed; other sites' scripts, framing by
40    /// other sites and plugins are not.
41    #[default]
42    Relaxed,
43    /// Scripts only from this site or with `nonce="{{ csp_nonce() }}"`, no
44    /// `eval`: Renox switches to Alpine's CSP build (expressions are limited
45    /// to properties and methods; put logic in `Alpine.data(...)`) and turns
46    /// off htmx's `eval`.
47    Strict,
48    /// No Content-Security-Policy header.
49    Off,
50}
51
52impl CspMode {
53    fn parse(value: &str) -> anyhow::Result<Self> {
54        match value.to_ascii_lowercase().as_str() {
55            "relaxed" | "" => Ok(Self::Relaxed),
56            "strict" => Ok(Self::Strict),
57            "off" | "false" | "none" => Ok(Self::Off),
58            other => bail!("CSP must be relaxed, strict or off, got `{other}`"),
59        }
60    }
61}
62
63setting_enum! {
64    /// Where sessions live, from `SESSION_DRIVER`.
65    pub enum SessionDriver ("SESSION_DRIVER") {
66        /// The whole session in an encrypted cookie (the default).
67        Cookie = "cookie",
68        /// An id in the cookie, the session in the `sessions` table.
69        Database = "database",
70    }
71}
72
73setting_enum! {
74    /// How log lines are written, from `LOG_FORMAT`.
75    pub enum LogFormat ("LOG_FORMAT") {
76        /// Readable lines (the default).
77        Text = "text",
78        /// One JSON object per line, for a log service.
79        Json = "json",
80    }
81}
82
83setting_enum! {
84    /// Where the cache keeps its values, from `CACHE_STORE`.
85    pub enum CacheStore ("CACHE_STORE") {
86        /// In this process's memory (the default).
87        Memory = "memory",
88        /// In the `cache` table, shared by every server (throttles and the login lock too).
89        Database = "database",
90    }
91}
92
93/// Application configuration, read from the process environment and `.env`.
94#[derive(Clone)]
95#[non_exhaustive]
96pub struct Config {
97    /// The application's name, from `APP_NAME` (Renox).
98    pub name: String,
99    /// The environment, from `APP_ENV` (local).
100    pub env: Environment,
101    /// Show error details and backtraces in responses. Never enable in production.
102    pub debug: bool,
103    /// The public base URL, from `APP_URL` (`http://{host}:{port}`).
104    pub url: String,
105    /// Secret used for signing and encryption. Required in production.
106    pub key: Option<String>,
107    /// The IP address to listen on, from `APP_HOST` (127.0.0.1).
108    pub host: IpAddr,
109    /// The port to listen on, from `APP_PORT` (3000).
110    pub port: u16,
111    /// Where templates live, from `VIEWS_PATH`.
112    pub views_path: PathBuf,
113    /// Files served as-is at the site root, from `PUBLIC_PATH`.
114    pub public_path: PathBuf,
115    /// Inactivity before a session expires, from `SESSION_LIFETIME` in
116    /// minutes (default 120).
117    pub session_lifetime: Duration,
118    /// Name of the session cookie, from `SESSION_COOKIE`.
119    pub session_cookie: String,
120    /// Where sessions live, from `SESSION_DRIVER`: `cookie` (default: the
121    /// whole session in an encrypted cookie) or `database` (the cookie holds
122    /// an id; the `sessions` table holds the rest).
123    pub session_driver: SessionDriver,
124    /// How long a "remember me" login lasts, from `REMEMBER_LIFETIME` in
125    /// minutes (default 43200: 30 days).
126    pub remember_lifetime: Duration,
127    /// SQLite database, from `DATABASE_URL`, e.g. `sqlite://storage/app.db`.
128    pub database_url: String,
129    /// Maximum open connections, from `DATABASE_POOL_SIZE`.
130    pub database_pool_size: u32,
131    /// How long a query waits for a free connection before failing, from
132    /// `DATABASE_ACQUIRE_TIMEOUT` in seconds (default 5).
133    pub database_acquire_timeout: Duration,
134    /// Longest a PostgreSQL statement may run, from `DATABASE_STATEMENT_TIMEOUT`
135    /// in seconds (default 30; 0 for none).
136    pub database_statement_timeout: Option<Duration>,
137    /// Longest a request may take to answer, from `REQUEST_TIMEOUT` in
138    /// seconds (default 60; 0 for none). Streaming a response isn't counted.
139    pub request_timeout: Option<Duration>,
140    /// Default language of the app, from `APP_LOCALE`. Renox's own texts are
141    /// English; other locales need a lang file (`resources/lang/es.json`).
142    pub locale: String,
143    /// Language used for keys missing in the request's locale, from `APP_FALLBACK_LOCALE`.
144    pub fallback_locale: String,
145    /// The currency of the `money` template filter, an ISO 4217 code, from
146    /// `APP_CURRENCY` (default `USD`): `USD` shows `$75.00`, `EUR`
147    /// `€75.00`, `IDR` `Rp 75,000`, with the page's separators.
148    pub currency: String,
149    /// Where translation files live, from `LANG_PATH`.
150    pub lang_path: PathBuf,
151    /// Mail settings, from `MAIL_*`.
152    pub mail: MailConfig,
153    /// Queue workers `serve` runs in-process, from `QUEUE_WORKERS` (0 turns them off).
154    pub queue_workers: usize,
155    /// Whether `serve` runs scheduled tasks, from `SCHEDULER`.
156    pub scheduler: bool,
157    /// `text` (default) or `json` (one object per line), from `LOG_FORMAT`.
158    pub log_format: LogFormat,
159    /// Where logs go instead of stdout, from `LOG_FILE` (appended to).
160    pub log_file: Option<PathBuf>,
161    /// The zone of scheduled times and the `date` filter, from
162    /// `APP_TIMEZONE`: an IANA name (`Asia/Jakarta`), an offset (`+07:00`) or
163    /// `UTC`. See [`crate::timezone::Zone`].
164    pub timezone: crate::timezone::Zone,
165    /// `memory` or `database`, from `CACHE_STORE`.
166    pub cache_store: CacheStore,
167    /// Where the app keeps runtime files (maintenance flag, uploads), from `STORAGE_PATH`.
168    pub storage_path: PathBuf,
169    /// File storage, from `STORAGE_DISK`, `S3_*` and `STORAGE_URL`.
170    pub storage: StorageConfig,
171    /// Largest request body in bytes, from `UPLOAD_MAX_SIZE` in megabytes (default 10).
172    pub upload_max_size: usize,
173    /// The Content-Security-Policy, from `CSP` (`relaxed`, `strict` or `off`).
174    pub csp: CspMode,
175    /// Values `var()` returns before looking at the environment, e.g. a
176    /// webhook secret set in a test with `TestApp::with_config`.
177    pub vars: std::collections::HashMap<String, String>,
178    /// Search engines and analytics; used only in production.
179    pub analytics: AnalyticsConfig,
180    /// Reverse proxies whose `X-Forwarded-For` is believed, from `TRUSTED_PROXIES`.
181    pub trusted_proxies: crate::TrustedProxies,
182    /// Host names the app answers, from `TRUSTED_HOSTS` (comma-separated;
183    /// `*.example.com` for subdomains). Empty: any host. When set, `APP_URL`'s
184    /// host is allowed too, and other hosts get a 400.
185    pub trusted_hosts: Vec<String>,
186}
187
188impl std::fmt::Debug for Config {
189    // Secrets show as `[hidden]`, so a logged config doesn't leak them.
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.debug_struct("Config")
192            .field("name", &self.name)
193            .field("env", &self.env)
194            .field("debug", &self.debug)
195            .field("url", &self.url)
196            .field("key", &self.key.as_ref().map(|_| "[hidden]"))
197            .field("host", &self.host)
198            .field("port", &self.port)
199            .field("views_path", &self.views_path)
200            .field("public_path", &self.public_path)
201            .field("session_lifetime", &self.session_lifetime)
202            .field("session_cookie", &self.session_cookie)
203            .field("session_driver", &self.session_driver)
204            .field("remember_lifetime", &self.remember_lifetime)
205            .field("database_url", &self.database_url)
206            .field("database_pool_size", &self.database_pool_size)
207            .field("database_acquire_timeout", &self.database_acquire_timeout)
208            .field(
209                "database_statement_timeout",
210                &self.database_statement_timeout,
211            )
212            .field("request_timeout", &self.request_timeout)
213            .field("locale", &self.locale)
214            .field("fallback_locale", &self.fallback_locale)
215            .field("currency", &self.currency)
216            .field("lang_path", &self.lang_path)
217            .field("mail", &self.mail)
218            .field("queue_workers", &self.queue_workers)
219            .field("scheduler", &self.scheduler)
220            .field("log_format", &self.log_format)
221            .field("log_file", &self.log_file)
222            .field("timezone", &self.timezone)
223            .field("cache_store", &self.cache_store)
224            .field("storage_path", &self.storage_path)
225            .field("storage", &self.storage)
226            .field("upload_max_size", &self.upload_max_size)
227            .field("csp", &self.csp)
228            .field(
229                "vars",
230                &self.vars.keys().collect::<std::collections::BTreeSet<_>>(),
231            )
232            .field("analytics", &self.analytics)
233            .field("trusted_proxies", &self.trusted_proxies)
234            .field("trusted_hosts", &self.trusted_hosts)
235            .finish()
236    }
237}
238
239/// Google Search Console, Google Analytics 4 and Google Tag Manager, from
240/// `GOOGLE_SITE_VERIFICATION`, `GA4_MEASUREMENT_ID`, `GA4_API_SECRET` and
241/// `GTM_CONTAINER_ID`. Tags are added to pages only in production.
242#[derive(Clone, Default)]
243#[non_exhaustive]
244pub struct AnalyticsConfig {
245    /// The `content` of Search Console's `google-site-verification` meta tag.
246    pub google_site_verification: Option<String>,
247    /// e.g. `G-XXXXXXXXXX`: adds the GA4 tag to every page.
248    pub ga4_measurement_id: Option<String>,
249    /// For events sent from the server (Measurement Protocol).
250    pub ga4_api_secret: Option<String>,
251    /// e.g. `GTM-XXXXXXX`: adds the Tag Manager container to every page.
252    pub gtm_container_id: Option<String>,
253}
254
255impl std::fmt::Debug for AnalyticsConfig {
256    // Secrets show as `[hidden]`, so a logged config doesn't leak them.
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.debug_struct("AnalyticsConfig")
259            .field("google_site_verification", &self.google_site_verification)
260            .field("ga4_measurement_id", &self.ga4_measurement_id)
261            .field(
262                "ga4_api_secret",
263                &self.ga4_api_secret.as_ref().map(|_| "[hidden]"),
264            )
265            .field("gtm_container_id", &self.gtm_container_id)
266            .finish()
267    }
268}
269
270impl Config {
271    /// Loads `.env` (if present) and reads the configuration from the environment.
272    pub fn load() -> crate::Result<Self> {
273        let _ = dotenvy::dotenv();
274        Self::from_env()
275    }
276
277    /// Reads the configuration from the environment only, without loading `.env`.
278    pub fn from_env() -> crate::Result<Self> {
279        Self::from_vars(|name| env::var(name).ok())
280    }
281
282    /// Reads the configuration from `get` (a variable's value by name), e.g.
283    /// a map in a test: `Config::from_vars(|name| vars.get(name).cloned())`.
284    /// A value that doesn't fit is an error naming its variable.
285    pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> crate::Result<Self> {
286        Ok(Self::read(get)?)
287    }
288
289    fn read(get: impl Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
290        let v = Vars(get);
291        let env = Environment::parse(&v.or("APP_ENV", "local"))?;
292        let debug = v.bool("APP_DEBUG", env == Environment::Local)?;
293        let host = v
294            .or("APP_HOST", "127.0.0.1")
295            .parse()
296            .context("APP_HOST must be an IP address")?;
297        let port = v
298            .or("APP_PORT", "3000")
299            .parse()
300            .context("APP_PORT must be a port number")?;
301        let key = v.get("APP_KEY").filter(|k| !k.is_empty());
302
303        if env == Environment::Production && key.is_none() {
304            bail!("APP_KEY must be set in production");
305        }
306
307        Ok(Self {
308            name: v.or("APP_NAME", "Renox"),
309            env,
310            debug,
311            url: v.or("APP_URL", &format!("http://{host}:{port}")),
312            key,
313            host,
314            port,
315            views_path: v.or("VIEWS_PATH", "resources/views").into(),
316            public_path: v.or("PUBLIC_PATH", "public").into(),
317            session_lifetime: Duration::from_secs(
318                v.or("SESSION_LIFETIME", "120")
319                    .parse::<u64>()
320                    .context("SESSION_LIFETIME must be a number of minutes")?
321                    .saturating_mul(60),
322            ),
323            session_cookie: v.or("SESSION_COOKIE", "renox_session"),
324            session_driver: SessionDriver::parse(&v.or("SESSION_DRIVER", "cookie"))?,
325            remember_lifetime: Duration::from_secs(
326                v.or("REMEMBER_LIFETIME", "43200")
327                    .parse::<u64>()
328                    .context("REMEMBER_LIFETIME must be a number of minutes")?
329                    .saturating_mul(60),
330            ),
331            database_url: v.or("DATABASE_URL", "sqlite://storage/app.db"),
332            database_pool_size: v
333                .or("DATABASE_POOL_SIZE", "8")
334                .parse()
335                .ok()
336                .filter(|n| *n > 0)
337                .context("DATABASE_POOL_SIZE must be a number above 0")?,
338            database_acquire_timeout: v
339                .seconds("DATABASE_ACQUIRE_TIMEOUT", 5)?
340                .unwrap_or(Duration::from_secs(5)),
341            database_statement_timeout: v.seconds("DATABASE_STATEMENT_TIMEOUT", 30)?,
342            request_timeout: v.seconds("REQUEST_TIMEOUT", 60)?,
343            locale: v.or("APP_LOCALE", "en"),
344            fallback_locale: v.or("APP_FALLBACK_LOCALE", "en"),
345            currency: match v.or("APP_CURRENCY", "USD").trim().to_ascii_uppercase() {
346                code if code.len() == 3 && code.bytes().all(|b| b.is_ascii_uppercase()) => code,
347                other => {
348                    bail!("APP_CURRENCY must be an ISO 4217 code like USD or EUR, got `{other}`")
349                }
350            },
351            lang_path: v.or("LANG_PATH", "resources/lang").into(),
352            mail: MailConfig {
353                mailer: crate::mail::MailDriver::parse(&v.or("MAIL_MAILER", "log"))?,
354                host: v.or("MAIL_HOST", "localhost"),
355                port: v
356                    .get("MAIL_PORT")
357                    .filter(|p| !p.is_empty())
358                    .map(|p| p.parse())
359                    .transpose()
360                    .context("MAIL_PORT must be a port number")?,
361                username: v.get("MAIL_USERNAME").filter(|v| !v.is_empty()),
362                password: v.get("MAIL_PASSWORD").filter(|v| !v.is_empty()),
363                encryption: crate::mail::MailEncryption::parse(
364                    &v.or("MAIL_ENCRYPTION", "starttls"),
365                )?,
366                from_address: v.or("MAIL_FROM_ADDRESS", "hello@example.com"),
367                from_name: v.get("MAIL_FROM_NAME").filter(|v| !v.is_empty()),
368                timeout: v
369                    .seconds("MAIL_TIMEOUT", 10)?
370                    .unwrap_or(Duration::from_secs(10)),
371                failover: v
372                    .or("MAIL_FAILOVER", "")
373                    .split(',')
374                    .map(|name| name.trim().to_owned())
375                    .filter(|name| !name.is_empty())
376                    .collect(),
377            },
378            queue_workers: v
379                .or("QUEUE_WORKERS", "2")
380                .parse()
381                .context("QUEUE_WORKERS must be a number")?,
382            scheduler: v.bool("SCHEDULER", true)?,
383            log_format: LogFormat::parse(&v.or("LOG_FORMAT", "text"))?,
384            log_file: v
385                .get("LOG_FILE")
386                .filter(|p| !p.is_empty())
387                .map(PathBuf::from),
388            timezone: v
389                .or("APP_TIMEZONE", "UTC")
390                .parse()
391                .map_err(|err| anyhow::anyhow!("APP_TIMEZONE: {err}"))?,
392            cache_store: CacheStore::parse(&v.or("CACHE_STORE", "memory"))?,
393            storage_path: v.or("STORAGE_PATH", "storage").into(),
394            storage: StorageConfig {
395                disk: crate::storage::DiskDriver::parse(&v.or("STORAGE_DISK", "local"))?,
396                bucket: v.optional("S3_BUCKET"),
397                region: v.optional("S3_REGION"),
398                endpoint: v.optional("S3_ENDPOINT"),
399                access_key_id: v.optional("S3_ACCESS_KEY_ID"),
400                secret_access_key: v.optional("S3_SECRET_ACCESS_KEY"),
401                url: v.optional("STORAGE_URL"),
402                root: None,
403            },
404            upload_max_size: v
405                .or("UPLOAD_MAX_SIZE", "10")
406                .parse::<usize>()
407                .ok()
408                .and_then(|mb| mb.checked_mul(1024 * 1024))
409                .context("UPLOAD_MAX_SIZE must be a number of megabytes")?,
410            csp: CspMode::parse(&v.or("CSP", "relaxed"))?,
411            trusted_proxies: crate::TrustedProxies::read(&v.or("TRUSTED_PROXIES", ""))?,
412            trusted_hosts: v
413                .or("TRUSTED_HOSTS", "")
414                .split(',')
415                .map(|host| host.trim().to_ascii_lowercase())
416                .filter(|host| !host.is_empty())
417                .collect(),
418            vars: Default::default(),
419            analytics: AnalyticsConfig {
420                google_site_verification: v.optional("GOOGLE_SITE_VERIFICATION"),
421                ga4_measurement_id: v.optional("GA4_MEASUREMENT_ID"),
422                ga4_api_secret: v.optional("GA4_API_SECRET"),
423                gtm_container_id: v.optional("GTM_CONTAINER_ID"),
424            },
425        })
426    }
427
428    /// Any other setting, e.g. an API key: `config.vars`, else the
429    /// environment (which includes `.env`). Empty values count as missing.
430    pub fn var(&self, name: &str) -> Option<String> {
431        self.vars
432            .get(name)
433            .cloned()
434            .or_else(|| env::var(name).ok())
435            .filter(|v| !v.is_empty())
436    }
437
438    /// The address to listen on: `host` and `port`.
439    pub fn addr(&self) -> SocketAddr {
440        SocketAddr::new(self.host, self.port)
441    }
442}
443
444impl Default for Config {
445    fn default() -> Self {
446        Self {
447            name: "Renox".into(),
448            env: Environment::Local,
449            debug: true,
450            url: "http://127.0.0.1:3000".into(),
451            key: None,
452            host: IpAddr::V4(Ipv4Addr::LOCALHOST),
453            port: 3000,
454            views_path: "resources/views".into(),
455            public_path: "public".into(),
456            session_lifetime: Duration::from_secs(120 * 60),
457            session_cookie: "renox_session".into(),
458            session_driver: SessionDriver::Cookie,
459            remember_lifetime: Duration::from_secs(43_200 * 60),
460            database_url: "sqlite::memory:".into(),
461            database_pool_size: 8,
462            // Generous: parallel test suites open many SQLite files at once.
463            // (An in-memory database still fails fast; see `db::connect`.)
464            database_acquire_timeout: Duration::from_secs(30),
465            database_statement_timeout: Some(Duration::from_secs(30)),
466            request_timeout: Some(Duration::from_secs(60)),
467            locale: "en".into(),
468            fallback_locale: "en".into(),
469            currency: "USD".into(),
470            lang_path: "resources/lang".into(),
471            mail: MailConfig {
472                mailer: crate::mail::MailDriver::Memory,
473                ..MailConfig::default()
474            },
475            queue_workers: 0,
476            scheduler: false,
477            log_format: LogFormat::Text,
478            log_file: None,
479            timezone: crate::timezone::Zone::default(),
480            cache_store: CacheStore::Memory,
481            storage_path: "storage".into(),
482            storage: StorageConfig::default(),
483            upload_max_size: 10 * 1024 * 1024,
484            csp: CspMode::Relaxed,
485            vars: Default::default(),
486            analytics: AnalyticsConfig::default(),
487            trusted_proxies: Default::default(),
488            trusted_hosts: Vec::new(),
489        }
490    }
491}
492
493/// Variables by name, for `Config::from_vars`.
494struct Vars<F>(F);
495
496impl<F: Fn(&str) -> Option<String>> Vars<F> {
497    fn get(&self, name: &str) -> Option<String> {
498        (self.0)(name)
499    }
500
501    fn optional(&self, name: &str) -> Option<String> {
502        self.get(name).filter(|v| !v.is_empty())
503    }
504
505    fn or(&self, name: &str, default: &str) -> String {
506        self.get(name).unwrap_or_else(|| default.to_owned())
507    }
508
509    /// A number of seconds from `name`; `None` when it is 0 (no limit).
510    fn seconds(&self, name: &str, default: u64) -> anyhow::Result<Option<Duration>> {
511        let value: u64 = self
512            .or(name, &default.to_string())
513            .parse()
514            .with_context(|| format!("{name} must be a number of seconds"))?;
515        Ok((value > 0).then(|| Duration::from_secs(value)))
516    }
517
518    fn bool(&self, name: &str, default: bool) -> anyhow::Result<bool> {
519        match self.get(name) {
520            None => Ok(default),
521            Some(v) => match v.to_ascii_lowercase().as_str() {
522                "1" | "true" | "yes" | "on" => Ok(true),
523                "0" | "false" | "no" | "off" | "" => Ok(false),
524                other => bail!("{name} must be true or false, got `{other}`"),
525            },
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn parses_environment_aliases() {
536        assert_eq!(Environment::parse("local").unwrap(), Environment::Local);
537        assert_eq!(Environment::parse("DEV").unwrap(), Environment::Local);
538        assert_eq!(Environment::parse("test").unwrap(), Environment::Testing);
539        assert_eq!(Environment::parse("prod").unwrap(), Environment::Production);
540        assert!(Environment::parse("staging").is_err());
541    }
542
543    fn load(pairs: &[(&str, &str)]) -> anyhow::Result<Config> {
544        let vars: std::collections::HashMap<String, String> = pairs
545            .iter()
546            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
547            .collect();
548        Config::read(|name| vars.get(name).cloned())
549    }
550
551    #[test]
552    fn defaults_without_any_variable() {
553        let c = load(&[]).unwrap();
554        assert_eq!(c.env, Environment::Local);
555        assert!(c.debug, "local defaults to debug");
556        assert_eq!(
557            (c.port, c.database_pool_size, c.upload_max_size),
558            (3000, 8, 10 * 1024 * 1024)
559        );
560        assert_eq!(c.database_acquire_timeout, Duration::from_secs(5));
561        assert_eq!(c.request_timeout, Some(Duration::from_secs(60)));
562        assert_eq!(c.cache_store, CacheStore::Memory);
563        assert_eq!(c.csp, CspMode::Relaxed);
564        assert!(c.key.is_none() && c.mail.port.is_none());
565    }
566
567    #[test]
568    fn debug_hides_secrets() {
569        let mut c = load(&[("APP_KEY", "base64:c2VjcmV0"), ("MAIL_PASSWORD", "hunter2")]).unwrap();
570        c.vars
571            .insert("XENDIT_SECRET_KEY".into(), "xnd_live_123".into());
572        let shown = format!("{c:?}");
573        for secret in ["c2VjcmV0", "hunter2", "xnd_live_123"] {
574            assert!(!shown.contains(secret), "{secret} in {shown}");
575        }
576        assert!(
577            shown.contains("XENDIT_SECRET_KEY"),
578            "variable names stay: {shown}"
579        );
580        assert!(shown.contains("[hidden]"));
581    }
582
583    #[test]
584    fn session_driver() {
585        assert_eq!(load(&[]).unwrap().session_driver, SessionDriver::Cookie);
586        assert_eq!(
587            load(&[("SESSION_DRIVER", "database")])
588                .unwrap()
589                .session_driver,
590            SessionDriver::Database
591        );
592        assert!(load(&[("SESSION_DRIVER", "redis")]).is_err());
593    }
594
595    #[test]
596    fn log_format_and_file() {
597        let c = load(&[]).unwrap();
598        assert_eq!((c.log_format.as_str(), c.log_file), ("text", None));
599        let c = load(&[("LOG_FORMAT", "json"), ("LOG_FILE", "storage/logs/app.log")]).unwrap();
600        assert_eq!(c.log_format, LogFormat::Json);
601        assert_eq!(c.log_file, Some(PathBuf::from("storage/logs/app.log")));
602        let err = load(&[("LOG_FORMAT", "xml")]).unwrap_err();
603        assert!(err.to_string().contains("LOG_FORMAT"), "{err}");
604    }
605
606    #[test]
607    fn currency() {
608        assert_eq!(load(&[]).unwrap().currency, "USD");
609        assert_eq!(load(&[("APP_CURRENCY", " idr ")]).unwrap().currency, "IDR");
610        for bad in ["US", "rupiah", "U$D"] {
611            let err = load(&[("APP_CURRENCY", bad)]).unwrap_err();
612            assert!(err.to_string().contains("APP_CURRENCY"), "{err}");
613        }
614    }
615
616    #[test]
617    fn reads_every_kind_of_value() {
618        let c = load(&[
619            ("APP_ENV", "production"),
620            ("APP_KEY", "base64:abc"),
621            ("APP_DEBUG", "off"),
622            ("APP_PORT", "8080"),
623            ("DATABASE_POOL_SIZE", "3"),
624            ("REQUEST_TIMEOUT", "0"),
625            ("DATABASE_STATEMENT_TIMEOUT", "5"),
626            ("UPLOAD_MAX_SIZE", "2"),
627            ("MAIL_PORT", "2525"),
628            ("MAIL_USERNAME", ""),
629            ("MAIL_FAILOVER", "backup, log"),
630            ("CSP", "strict"),
631            ("TRUSTED_PROXIES", "10.0.0.0/8"),
632            ("TRUSTED_HOSTS", "shop.example.com, *.Example.org"),
633            ("S3_BUCKET", "files"),
634        ])
635        .unwrap();
636        assert_eq!(c.env, Environment::Production);
637        assert!(!c.debug);
638        assert_eq!((c.port, c.database_pool_size), (8080, 3));
639        assert_eq!(c.request_timeout, None, "0 means no limit");
640        assert_eq!(c.database_statement_timeout, Some(Duration::from_secs(5)));
641        assert_eq!(c.upload_max_size, 2 * 1024 * 1024);
642        assert_eq!(c.mail.port, Some(2525));
643        assert_eq!(c.mail.username, None, "empty means unset");
644        assert_eq!(c.mail.failover, ["backup", "log"]);
645        assert_eq!(c.csp, CspMode::Strict);
646        assert!(c.trusted_proxies.contains("10.1.2.3".parse().unwrap()));
647        assert_eq!(c.trusted_hosts, ["shop.example.com", "*.example.org"]);
648        assert_eq!(c.storage.bucket.as_deref(), Some("files"));
649    }
650
651    #[test]
652    fn refuses_bad_values_with_the_variable_name() {
653        for (name, value) in [
654            ("APP_ENV", "staging"),
655            ("APP_DEBUG", "maybe"),
656            ("APP_PORT", "http"),
657            ("APP_HOST", "localhost"),
658            ("DATABASE_POOL_SIZE", "0"),
659            ("REQUEST_TIMEOUT", "-1"),
660            ("UPLOAD_MAX_SIZE", "99999999999999999"),
661            ("MAIL_PORT", "99999"),
662            ("CSP", "loose"),
663            ("TRUSTED_PROXIES", "proxy.local"),
664            ("SESSION_DRIVER", "redis"),
665            ("CACHE_STORE", "redis"),
666            ("LOG_FORMAT", "xml"),
667            ("MAIL_MAILER", "sendgrid"),
668            ("MAIL_ENCRYPTION", "ssl3"),
669            ("STORAGE_DISK", "ftp"),
670            ("APP_TIMEZONE", "Mars/Olympus"),
671        ] {
672            let err = load(&[(name, value)])
673                .err()
674                .map(|e| format!("{e:#}"))
675                .unwrap_or_default();
676            assert!(
677                err.contains(name) || err.contains(value),
678                "{name}={value}: {err}"
679            );
680        }
681        let err = load(&[("APP_ENV", "production")]).unwrap_err();
682        assert!(
683            format!("{err}").contains("APP_KEY"),
684            "production needs a key"
685        );
686    }
687
688    /// Settings print and serialize as the word `.env` takes.
689    #[test]
690    fn settings_print_and_serialize_as_their_word() {
691        assert_eq!(SessionDriver::Database.to_string(), "database");
692        assert_eq!(
693            serde_json::to_string(&CacheStore::Database).unwrap(),
694            r#""database""#
695        );
696        assert_eq!(format!("{}", LogFormat::Json), "json");
697    }
698}