1use serde::Deserialize;
2use std::{fmt, str::FromStr};
3
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
6#[non_exhaustive]
7pub enum Environment {
8 #[default]
10 Development,
11 Test,
13 Staging,
15 Production,
17}
18
19impl Environment {
20 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 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 pub const fn allows_development_tools(self) -> bool {
44 matches!(self, Self::Development)
45 }
46
47 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#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum ConfigError {
83 #[error("invalid Rullst environment `{0}`")]
85 InvalidEnvironment(String),
86
87 #[error("environment variable `{0}` is not valid Unicode")]
89 NonUnicodeEnvironmentVariable(String),
90
91 #[error("failed to read Rullst configuration: {0}")]
93 Read(String),
94
95 #[error("failed to parse Rullst configuration: {0}")]
97 Parse(String),
98
99 #[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]
116pub struct RullstConfig {
118 #[serde(default)]
119 pub app: AppConfig,
121 #[serde(default)]
122 pub database: DatabaseConfig,
124 #[serde(default)]
125 pub security: SecurityConfig,
127 #[serde(default)]
128 pub storage: StorageConfig,
130}
131
132#[derive(Debug, Clone, Deserialize, Default)]
133#[non_exhaustive]
134pub struct StorageConfig {
136 pub root: Option<String>,
138}
139
140#[derive(Debug, Clone, Deserialize, Default)]
141#[non_exhaustive]
142pub struct AppConfig {
144 pub env: Option<String>,
146 pub port: Option<u16>,
148}
149
150#[derive(Clone, Deserialize, Default)]
151#[non_exhaustive]
152pub struct DatabaseConfig {
157 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
169pub(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]
187pub struct SecurityConfig {
189 #[serde(default = "default_same_site")]
191 pub csrf_same_site: String,
192 #[serde(default)]
194 pub cors_allow_origins: Vec<String>,
195 #[serde(default = "default_false")]
197 pub cors_allow_credentials: bool,
198 #[serde(default = "default_csp")]
200 pub csp: String,
201 #[serde(default = "default_coep")]
206 pub coep: String,
207 #[serde(default = "default_user_agent_blocklist")]
211 pub user_agent_blocklist: Vec<String>,
212 #[serde(default = "default_false")]
214 pub enable_pii_masking: bool,
215 #[serde(default)]
218 pub csrf_signed_webhook_paths: Vec<String>,
219}
220
221pub 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 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 pub fn global() -> &'static RullstConfig {
349 GLOBAL_CONFIG.get_or_init(Self::default)
350 }
351
352 #[allow(clippy::result_large_err)]
354 pub fn set_global(config: Self) -> Result<(), Self> {
355 GLOBAL_CONFIG.set(config)
356 }
357
358 pub fn new() -> Self {
360 Self::default()
361 }
362
363 pub fn validate(&self) -> Result<(), ConfigError> {
365 self.security.validate()
366 }
367
368 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 pub fn environment(&self) -> Result<Environment, ConfigError> {
393 Environment::detect(self.app.env.as_deref())
394 }
395
396 #[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}