Skip to main content

rullst_core/
config.rs

1use serde::Deserialize;
2use std::{fmt, str::FromStr};
3
4/// Validated runtime environment shared by every Rullst subsystem.
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
6#[non_exhaustive]
7pub enum Environment {
8    /// Local development with developer tooling enabled.
9    #[default]
10    Development,
11    /// Automated test execution.
12    Test,
13    /// Production-like pre-release deployment.
14    Staging,
15    /// Public production deployment with all secure defaults enabled.
16    Production,
17}
18
19impl Environment {
20    /// Resolves environment sources using the documented precedence:
21    /// `RULLST_ENV`, legacy `APP_ENV`, then `[app].env` from `Rullst.toml`.
22    pub fn resolve(
23        rullst_env: Option<&str>,
24        app_env: Option<&str>,
25        configured_env: Option<&str>,
26    ) -> Result<Self, ConfigError> {
27        rullst_env
28            .or(app_env)
29            .or(configured_env)
30            .map(Self::from_str)
31            .transpose()
32            .map(|environment| environment.unwrap_or_default())
33    }
34
35    /// Resolves the environment from process variables and an optional config value.
36    pub fn detect(configured_env: Option<&str>) -> Result<Self, ConfigError> {
37        let rullst_env = read_environment_variable("RULLST_ENV")?;
38        let app_env = read_environment_variable("APP_ENV")?;
39        Self::resolve(rullst_env.as_deref(), app_env.as_deref(), configured_env)
40    }
41
42    /// Whether local-only developer tooling may be exposed.
43    pub const fn allows_development_tools(self) -> bool {
44        matches!(self, Self::Development)
45    }
46
47    /// Whether production security layers and secure cookies are mandatory.
48    pub const fn requires_secure_defaults(self) -> bool {
49        matches!(self, Self::Staging | Self::Production)
50    }
51}
52
53impl FromStr for Environment {
54    type Err = ConfigError;
55
56    fn from_str(value: &str) -> Result<Self, Self::Err> {
57        match value.trim().to_ascii_lowercase().as_str() {
58            "development" | "dev" | "local" => Ok(Self::Development),
59            "test" | "testing" => Ok(Self::Test),
60            "staging" | "stage" => Ok(Self::Staging),
61            "production" | "prod" => Ok(Self::Production),
62            _ => Err(ConfigError::InvalidEnvironment(value.to_string())),
63        }
64    }
65}
66
67impl fmt::Display for Environment {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        let value = match self {
70            Self::Development => "development",
71            Self::Test => "test",
72            Self::Staging => "staging",
73            Self::Production => "production",
74        };
75        formatter.write_str(value)
76    }
77}
78
79/// Strongly typed failures while loading Rullst configuration.
80#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum ConfigError {
83    /// A configured environment name is not recognized.
84    #[error("invalid Rullst environment `{0}`")]
85    InvalidEnvironment(String),
86
87    /// An environment variable contains non-Unicode data.
88    #[error("environment variable `{0}` is not valid Unicode")]
89    NonUnicodeEnvironmentVariable(String),
90
91    /// A configuration file could not be read.
92    #[error("failed to read Rullst configuration: {0}")]
93    Read(String),
94
95    /// TOML configuration is invalid.
96    #[error("failed to parse Rullst configuration: {0}")]
97    Parse(String),
98
99    /// A security policy is malformed or would create an ambiguous exemption.
100    #[error("invalid security configuration: {0}")]
101    InvalidSecurityConfiguration(String),
102}
103
104fn read_environment_variable(name: &str) -> Result<Option<String>, ConfigError> {
105    match std::env::var(name) {
106        Ok(value) => Ok(Some(value)),
107        Err(std::env::VarError::NotPresent) => Ok(None),
108        Err(std::env::VarError::NotUnicode(_)) => {
109            Err(ConfigError::NonUnicodeEnvironmentVariable(name.to_string()))
110        }
111    }
112}
113
114#[derive(Debug, Clone, Deserialize, Default)]
115#[non_exhaustive]
116/// Main configuration container for Rullst applications.
117pub struct RullstConfig {
118    #[serde(default)]
119    /// General application settings.
120    pub app: AppConfig,
121    #[serde(default)]
122    /// Database settings.
123    pub database: DatabaseConfig,
124    #[serde(default)]
125    /// Security policies and configuration parameters.
126    pub security: SecurityConfig,
127    #[serde(default)]
128    /// File storage settings.
129    pub storage: StorageConfig,
130}
131
132#[derive(Debug, Clone, Deserialize, Default)]
133#[non_exhaustive]
134/// Configuration settings for storage drivers.
135pub struct StorageConfig {
136    /// The root directory for filesystem storage.
137    pub root: Option<String>,
138}
139
140#[derive(Debug, Clone, Deserialize, Default)]
141#[non_exhaustive]
142/// General configuration options for the application instance.
143pub struct AppConfig {
144    /// Environment profile of the application (e.g. "development", "production").
145    pub env: Option<String>,
146    /// The port number that the HTTP server will bind to.
147    pub port: Option<u16>,
148}
149
150#[derive(Clone, Deserialize, Default)]
151#[non_exhaustive]
152/// Database connection configuration.
153///
154/// `Debug` prints only the URL scheme (for example `postgres://<redacted>`),
155/// never credentials, host, path or query parameters.
156pub struct DatabaseConfig {
157    /// Database connection URL (e.g., `sqlite://rullst.db`).
158    pub url: Option<String>,
159}
160
161impl fmt::Debug for DatabaseConfig {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        f.debug_struct("DatabaseConfig")
164            .field("url", &self.url.as_deref().map(redacted_url))
165            .finish()
166    }
167}
168
169/// Debug form of a connection URL: the scheme only, or `<redacted>` when no
170/// plain scheme is present. Userinfo, host, path and query are never shown.
171pub(crate) fn redacted_url(url: &str) -> String {
172    match url.split_once("://") {
173        Some((scheme, _))
174            if (1..=32).contains(&scheme.len())
175                && scheme
176                    .bytes()
177                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) =>
178        {
179            format!("{scheme}://<redacted>")
180        }
181        _ => "<redacted>".to_string(),
182    }
183}
184
185#[derive(Debug, Clone, Deserialize)]
186#[non_exhaustive]
187/// Configuration options for application security layers.
188pub struct SecurityConfig {
189    /// SameSite policy for the CSRF cookie ("Lax", "Strict", or "None").
190    #[serde(default = "default_same_site")]
191    pub csrf_same_site: String,
192    /// List of allowed origins for Cross-Origin Resource Sharing (CORS).
193    #[serde(default)]
194    pub cors_allow_origins: Vec<String>,
195    /// Allow browser credentials only for the exact configured CORS origins.
196    #[serde(default = "default_false")]
197    pub cors_allow_credentials: bool,
198    /// Content-Security-Policy (CSP) header value.
199    #[serde(default = "default_csp")]
200    pub csp: String,
201    /// Cross-Origin-Embedder-Policy value. The strict default is `require-corp`;
202    /// applications that deliberately embed reviewed credentialless cross-origin
203    /// media can opt into `credentialless`, while `unsafe-none` must be an
204    /// explicit application-level decision.
205    #[serde(default = "default_coep")]
206    pub coep: String,
207    /// Case-insensitive User-Agent substrings to block in the WAF middleware.
208    /// Defaults cover selected crawlers, not general HTTP clients or health probes.
209    /// This forgeable header is a traffic preference, never authentication.
210    #[serde(default = "default_user_agent_blocklist")]
211    pub user_agent_blocklist: Vec<String>,
212    /// Enable global automatic PII masking middleware on all textual responses (heavy performance cost).
213    #[serde(default = "default_false")]
214    pub enable_pii_masking: bool,
215    /// Exact POST paths that are exempt from browser CSRF tokens because a mandatory signed
216    /// webhook middleware authenticates them. Wildcards and route parameters are rejected.
217    #[serde(default)]
218    pub csrf_signed_webhook_paths: Vec<String>,
219}
220
221/// Strict default CSP template. `{NONCE}` is replaced by the secure headers middleware for each
222/// request before the header is emitted.
223pub const DEFAULT_CSP_TEMPLATE: &str = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'nonce-{NONCE}'; style-src 'self' 'nonce-{NONCE}'; img-src 'self' data:; connect-src 'self'; font-src 'self'; worker-src 'self' blob:";
224
225fn default_csp() -> String {
226    DEFAULT_CSP_TEMPLATE.to_string()
227}
228
229fn default_coep() -> String {
230    "require-corp".to_string()
231}
232
233fn default_user_agent_blocklist() -> Vec<String> {
234    vec![
235        "gptbot".to_string(),
236        "chatgpt-user".to_string(),
237        "google-extended".to_string(),
238        "anthropic-ai".to_string(),
239        "claude-web".to_string(),
240        "cohere-ai".to_string(),
241        "bytespider".to_string(),
242        "mj12bot".to_string(),
243    ]
244}
245
246fn default_same_site() -> String {
247    "Lax".to_string()
248}
249
250fn default_false() -> bool {
251    false
252}
253
254impl Default for SecurityConfig {
255    fn default() -> Self {
256        Self {
257            csrf_same_site: default_same_site(),
258            cors_allow_origins: vec![],
259            cors_allow_credentials: false,
260            csp: default_csp(),
261            coep: default_coep(),
262            user_agent_blocklist: default_user_agent_blocklist(),
263            enable_pii_masking: false,
264            csrf_signed_webhook_paths: Vec::new(),
265        }
266    }
267}
268
269impl SecurityConfig {
270    /// Validates browser security policy and exact-path CSRF webhook exemptions.
271    pub fn validate(&self) -> Result<(), ConfigError> {
272        if !matches!(self.csrf_same_site.as_str(), "Lax" | "Strict" | "None") {
273            return Err(ConfigError::InvalidSecurityConfiguration(format!(
274                "CSRF SameSite policy `{}` must be Lax, Strict, or None",
275                self.csrf_same_site
276            )));
277        }
278        let rendered_csp = self.csp.replace("{NONCE}", "validation-nonce");
279        if self.csp.trim().is_empty() || rendered_csp.parse::<http::HeaderValue>().is_err() {
280            return Err(ConfigError::InvalidSecurityConfiguration(
281                "CSP must be a non-empty valid HTTP header value".to_string(),
282            ));
283        }
284        if !matches!(
285            self.coep.as_str(),
286            "require-corp" | "credentialless" | "unsafe-none"
287        ) {
288            return Err(ConfigError::InvalidSecurityConfiguration(format!(
289                "COEP policy `{}` must be require-corp, credentialless, or unsafe-none",
290                self.coep
291            )));
292        }
293        let mut unique_origins = std::collections::HashSet::new();
294        for origin in &self.cors_allow_origins {
295            let uri = origin.parse::<http::Uri>().map_err(|_| {
296                ConfigError::InvalidSecurityConfiguration(format!(
297                    "CORS origin `{origin}` must be an exact HTTP(S) origin"
298                ))
299            })?;
300            let valid_scheme = uri
301                .scheme_str()
302                .is_some_and(|scheme| matches!(scheme, "http" | "https"));
303            let valid_origin = valid_scheme
304                && uri.authority().is_some()
305                && uri.path() == "/"
306                && uri.query().is_none()
307                && !origin.ends_with('/')
308                && !origin.contains(['@', '#', '*'])
309                && origin.parse::<http::HeaderValue>().is_ok();
310            if !valid_origin {
311                return Err(ConfigError::InvalidSecurityConfiguration(format!(
312                    "CORS origin `{origin}` must be an exact HTTP(S) origin without path, query, credentials, wildcard, or trailing slash"
313                )));
314            }
315            if !unique_origins.insert(origin) {
316                return Err(ConfigError::InvalidSecurityConfiguration(format!(
317                    "duplicate CORS origin `{origin}`"
318                )));
319            }
320        }
321        let mut unique_paths = std::collections::HashSet::new();
322        for path in &self.csrf_signed_webhook_paths {
323            let is_exact_absolute_path = path.starts_with('/')
324                && path.len() > 1
325                && !path.contains(['?', '#', '*', '{', '}', ':'])
326                && !path
327                    .split('/')
328                    .any(|segment| segment == ".." || segment == ".");
329            if !is_exact_absolute_path {
330                return Err(ConfigError::InvalidSecurityConfiguration(format!(
331                    "CSRF signed-webhook exemption `{path}` must be an exact absolute path"
332                )));
333            }
334            if !unique_paths.insert(path) {
335                return Err(ConfigError::InvalidSecurityConfiguration(format!(
336                    "duplicate CSRF signed-webhook exemption `{path}`"
337                )));
338            }
339        }
340        Ok(())
341    }
342}
343
344static GLOBAL_CONFIG: std::sync::OnceLock<RullstConfig> = std::sync::OnceLock::new();
345
346impl RullstConfig {
347    /// Gets the global configuration reference, initializing it with default values if not set.
348    pub fn global() -> &'static RullstConfig {
349        GLOBAL_CONFIG.get_or_init(Self::default)
350    }
351
352    /// Sets the global configuration instance.
353    #[allow(clippy::result_large_err)]
354    pub fn set_global(config: Self) -> Result<(), Self> {
355        GLOBAL_CONFIG.set(config)
356    }
357
358    /// Creates a new `RullstConfig` with default values.
359    pub fn new() -> Self {
360        Self::default()
361    }
362
363    /// Validates cross-field configuration invariants before any global state is published.
364    pub fn validate(&self) -> Result<(), ConfigError> {
365        self.security.validate()
366    }
367
368    /// Parses a complete `Rullst.toml` document.
369    ///
370    /// A failure reports only the line and column. The `toml` error text quotes
371    /// the offending source line and can echo values, such as `app_key` or a
372    /// database URL, so it never reaches `ConfigError::Parse`.
373    pub fn from_toml(content: &str) -> Result<Self, ConfigError> {
374        toml::from_str(content).map_err(|error: toml::de::Error| {
375            ConfigError::Parse(match error.span() {
376                Some(span) => {
377                    let before = content.get(..span.start).unwrap_or(content);
378                    let line = before.matches('\n').count() + 1;
379                    let column = before
380                        .rsplit('\n')
381                        .next()
382                        .map_or(0, |text| text.chars().count())
383                        + 1;
384                    format!("invalid configuration at line {line}, column {column}")
385                }
386                None => "invalid configuration".to_string(),
387            })
388        })
389    }
390
391    /// Resolves the validated runtime environment for this configuration.
392    pub fn environment(&self) -> Result<Environment, ConfigError> {
393        Environment::detect(self.app.env.as_deref())
394    }
395
396    /// Loads and parses the configuration from a TOML file.
397    #[cfg(not(target_arch = "wasm32"))]
398    pub async fn load_from_file(path: &str) -> Result<Self, ConfigError> {
399        let content = tokio::fs::read_to_string(path)
400            .await
401            .map_err(|error| ConfigError::Read(error.to_string()))?;
402        Self::from_toml(&content)
403    }
404}
405
406#[cfg(test)]
407#[allow(
408    clippy::unwrap_used,
409    clippy::expect_used,
410    clippy::field_reassign_with_default
411)]
412mod tests {
413    use super::*;
414
415    #[tokio::test]
416    async fn test_global_config_access() {
417        let config1 = RullstConfig::global();
418        let config2 = RullstConfig::global();
419        assert!(
420            std::ptr::eq(config1, config2),
421            "global() should return the same instance"
422        );
423        assert_eq!(config1.security.csrf_same_site, "Lax");
424    }
425
426    #[test]
427    fn parse_errors_report_position_without_configuration_content() {
428        let canary = "sk_live_toml_redaction_canary";
429        for (content, line) in [
430            (
431                format!(
432                    "[app]\nenv = \"production\"\n[database]\nurl = \"postgres://owner:{canary}@db\n"
433                ),
434                4,
435            ),
436            (format!("[app]\nport = \"{canary}\"\n"), 2),
437            (format!("app_key = \"{canary}\"\n[app\n"), 2),
438        ] {
439            let error = RullstConfig::from_toml(&content).unwrap_err();
440            let rendered = format!("{error} {error:?}");
441            assert!(!rendered.contains(canary), "{rendered}");
442            assert!(!rendered.contains("postgres://"), "{rendered}");
443            assert!(
444                matches!(&error, ConfigError::Parse(message) if message.contains(&format!("line {line},"))),
445                "{rendered}"
446            );
447        }
448    }
449
450    #[tokio::test]
451    async fn test_load_config_from_file() {
452        let temp_dir = "test_config_dir";
453        let _ = std::fs::create_dir_all(temp_dir);
454        let path = format!("{}/Rullst.toml", temp_dir);
455
456        let toml_content = r#"
457[app]
458env = "production"
459port = 8080
460
461[database]
462url = "sqlite::memory:"
463
464[security]
465csrf_same_site = "Strict"
466cors_allow_origins = ["https://example.com"]
467"#;
468        tokio::fs::write(&path, toml_content).await.unwrap();
469
470        let config = RullstConfig::load_from_file(&path).await.unwrap();
471
472        assert_eq!(config.app.env.unwrap(), "production");
473        assert_eq!(config.app.port.unwrap(), 8080);
474        assert_eq!(config.database.url.unwrap(), "sqlite::memory:");
475        assert_eq!(config.security.csrf_same_site, "Strict");
476        assert_eq!(config.security.cors_allow_origins.len(), 1);
477        assert_eq!(config.security.cors_allow_origins[0], "https://example.com");
478        assert!(!config.security.cors_allow_credentials);
479
480        let _ = std::fs::remove_dir_all(temp_dir);
481    }
482
483    #[test]
484    fn test_default_security_config() {
485        let config = SecurityConfig::default();
486        assert_eq!(config.csrf_same_site, "Lax");
487        assert_eq!(config.coep, "require-corp");
488        assert!(config.csp.contains("default-src"));
489        assert!(!config.csp.contains("unsafe-inline"));
490        assert!(!config.csp.contains("unsafe-eval"));
491        assert!(config.user_agent_blocklist.contains(&"gptbot".to_string()));
492        assert!(config.csrf_signed_webhook_paths.is_empty());
493    }
494
495    #[test]
496    fn test_set_global_config() {
497        let mut config = RullstConfig::new();
498        config.app.env = Some("test_env".to_string());
499        let result = RullstConfig::set_global(config);
500        match result {
501            Ok(_) => assert_eq!(RullstConfig::global().app.env.as_deref(), Some("test_env")),
502            Err(c) => assert_eq!(c.app.env.as_deref(), Some("test_env")),
503        }
504    }
505
506    #[test]
507    fn database_url_debug_output_is_redacted() {
508        let mut config = RullstConfig::default();
509        config.database.url = Some("postgres://app:S3cr3t@db.internal/prod?sslmode=require".into());
510        let debug = format!("{config:?} {:?}", config.database);
511        assert!(
512            !debug.contains("S3cr3t"),
513            "Debug must redact the URL password"
514        );
515        assert!(
516            !debug.contains("db.internal"),
517            "Debug must redact the URL host"
518        );
519        assert!(
520            debug.contains("postgres://<redacted>"),
521            "Debug must keep the URL scheme"
522        );
523
524        assert_eq!(redacted_url("app:S3cr3t@db/prod"), "<redacted>");
525        assert_eq!(redacted_url("sqlite://rullst.db"), "sqlite://<redacted>");
526        assert_eq!(redacted_url("a b://x"), "<redacted>");
527        assert!(format!("{:?}", DatabaseConfig::default()).contains("url: None"));
528    }
529
530    #[test]
531    fn test_deserialize_security_config_defaults() {
532        let config: SecurityConfig = toml::from_str("").unwrap();
533        assert!(!config.enable_pii_masking);
534        assert_eq!(config.coep, "require-corp");
535    }
536
537    #[test]
538    fn environment_resolution_has_one_precedence_and_validated_aliases() {
539        assert_eq!(
540            Environment::resolve(Some("prod"), Some("test"), Some("development")).unwrap(),
541            Environment::Production
542        );
543        assert_eq!(
544            Environment::resolve(None, Some("STAGE"), Some("development")).unwrap(),
545            Environment::Staging
546        );
547        assert_eq!(
548            Environment::resolve(None, None, Some("testing")).unwrap(),
549            Environment::Test
550        );
551        assert_eq!(
552            Environment::resolve(None, None, None).unwrap(),
553            Environment::Development
554        );
555        assert!(Environment::resolve(Some("unknown"), None, None).is_err());
556    }
557
558    #[test]
559    fn only_development_exposes_developer_tools() {
560        assert!(Environment::Development.allows_development_tools());
561        assert!(!Environment::Test.allows_development_tools());
562        assert!(Environment::Staging.requires_secure_defaults());
563        assert!(Environment::Production.requires_secure_defaults());
564    }
565
566    #[test]
567    fn signed_webhook_csrf_exemptions_must_be_exact_paths() {
568        let mut config = SecurityConfig::default();
569        config.csrf_signed_webhook_paths = vec!["/billing/webhook".to_owned()];
570        assert!(config.validate().is_ok());
571
572        for invalid in [
573            "billing/webhook",
574            "/billing/:provider",
575            "/billing/{provider}",
576            "/billing/*path",
577            "/billing/../admin",
578            "/billing/webhook?provider=x",
579        ] {
580            config.csrf_signed_webhook_paths = vec![invalid.to_owned()];
581            assert!(config.validate().is_err(), "{invalid} must be rejected");
582        }
583
584        config.csrf_signed_webhook_paths =
585            vec!["/billing/webhook".to_owned(), "/billing/webhook".to_owned()];
586        assert!(config.validate().is_err());
587    }
588
589    #[test]
590    fn browser_security_configuration_is_strict_and_exact() {
591        let mut config = SecurityConfig::default();
592        config.cors_allow_origins = vec![
593            "https://academy.example".to_string(),
594            "http://localhost:3000".to_string(),
595        ];
596        assert!(config.validate().is_ok());
597
598        for invalid in [
599            "*",
600            "academy.example",
601            "ftp://academy.example",
602            "https://academy.example/",
603            "https://academy.example/path",
604            "https://user@academy.example",
605            "https://academy.example?x=1",
606        ] {
607            config.cors_allow_origins = vec![invalid.to_string()];
608            assert!(config.validate().is_err(), "{invalid} must be rejected");
609        }
610        config.cors_allow_origins = vec![
611            "https://academy.example".to_string(),
612            "https://academy.example".to_string(),
613        ];
614        assert!(config.validate().is_err());
615        config.cors_allow_origins.clear();
616        config.csrf_same_site = "relaxed".to_string();
617        assert!(config.validate().is_err());
618        config.csrf_same_site = "Lax".to_string();
619        config.csp = "default-src 'self'\r\nx-injected: yes".to_string();
620        assert!(config.validate().is_err());
621    }
622
623    #[test]
624    fn coep_policy_is_explicit_and_closed() {
625        let mut config = SecurityConfig::default();
626        for policy in ["require-corp", "credentialless", "unsafe-none"] {
627            config.coep = policy.to_string();
628            assert!(config.validate().is_ok(), "{policy} must be accepted");
629        }
630        for invalid in ["", "off", "cross-origin", "require-corp\nunsafe-none"] {
631            config.coep = invalid.to_string();
632            assert!(config.validate().is_err(), "{invalid:?} must be rejected");
633        }
634    }
635}