ch_config/config/
model.rs1use super::Result;
2use super::{CaptchaConfig, EmailConfig, MariadbConfig, MediaLibraryConfig, SpacetimeDbConfig};
3
4#[derive(Clone, Debug, Default)]
6pub struct AppConfig {
7 pub mariadb: MariadbConfig,
9 pub spacetimedb: SpacetimeDbConfig,
11 pub media_library: MediaLibraryConfig,
13 pub captcha: CaptchaConfig,
15 pub email: EmailConfig,
17}
18
19impl AppConfig {
20 pub fn from_env(mut defaults: Self) -> Result<Self> {
23 defaults.apply_environment(&mut crate::environment::value)?;
24 defaults.validate()?;
25 Ok(defaults)
26 }
27
28 fn apply_environment(
29 &mut self,
30 value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
31 ) -> Result<()> {
32 self.mariadb.apply_environment(value)?;
33 self.spacetimedb.apply_environment(value)?;
34 self.media_library.apply_environment(value)?;
35 self.captcha.apply_environment(value)?;
36 self.email.apply_environment(value)?;
37 Ok(())
38 }
39
40 pub fn validate(&self) -> Result<()> {
42 self.mariadb.validate()?;
43 self.spacetimedb.validate()?;
44 self.media_library.validate()?;
45 self.captcha.validate()?;
46 self.email.validate()?;
47 Ok(())
48 }
49}
50
51#[cfg(test)]
52mod tests {
53 use std::collections::HashMap;
54
55 use super::*;
56
57 #[test]
58 fn empty_defaults_are_valid() {
59 AppConfig::default()
60 .validate()
61 .expect("an application may opt into only the sections it uses");
62 }
63
64 #[test]
65 fn environment_values_override_only_supplied_defaults() {
66 let mut values = HashMap::from([
67 ("DATABASE_URL", "mysql://database.invalid/app".to_string()),
68 ("CAPTCHA_BYPASS", "true".to_string()),
69 ]);
70 let mut config = AppConfig {
71 captcha: CaptchaConfig::standard(),
72 ..AppConfig::default()
73 };
74
75 config
76 .apply_environment(&mut |name| Ok(values.remove(name)))
77 .expect("environment values should resolve");
78 config
79 .validate()
80 .expect("resolved defaults should validate");
81
82 assert_eq!(
83 config.mariadb.connection_url().expect("database URL"),
84 "mysql://database.invalid/app"
85 );
86 assert!(config.captcha.bypass);
87 }
88
89 #[test]
90 fn public_parsers_return_sanitized_concrete_errors() {
91 use super::super::{EmailSmtpSecurity, EmailTransport, MediaLibraryBackend};
92
93 fn concrete_error<T: std::error::Error + Send + Sync + 'static>(_: &T) {}
94 for error in [
95 "private-rejected-value"
96 .parse::<EmailTransport>()
97 .unwrap_err(),
98 "private-rejected-value"
99 .parse::<EmailSmtpSecurity>()
100 .unwrap_err(),
101 "private-rejected-value"
102 .parse::<MediaLibraryBackend>()
103 .unwrap_err(),
104 ] {
105 concrete_error(&error);
106 assert!(!error.to_string().contains("private-rejected-value"));
107 assert!(!format!("{error:?}").contains("private-rejected-value"));
108 }
109 }
110
111 #[test]
112 fn debug_output_redacts_credentials() {
113 let config = AppConfig {
114 mariadb: MariadbConfig::new("mysql://user:password@database.invalid/schema"),
115 email: EmailConfig::smtp(
116 "sender@example.invalid",
117 "Example",
118 "mail.example.invalid",
119 2525,
120 super::super::EmailSmtpSecurity::StartTls,
121 ),
122 ..AppConfig::default()
123 };
124 let mut config = config;
125 config.email.smtp.username = Some("account".to_string());
126 config.email.smtp.password = Some("secret".to_string());
127
128 config.spacetimedb =
129 SpacetimeDbConfig::new("https://database.invalid?token=spacetime-secret");
130 config.media_library.object_root = "objects".into();
131 config.media_library.s3.bucket = "media".into();
132 config.media_library.s3.endpoint =
133 Some("https://storage.invalid?token=storage-secret".into());
134 config.email.mailersend.api_url = "https://mail.invalid?token=mail-secret".into();
135 config
136 .validate()
137 .expect("query parameters are valid configuration");
138
139 let debug = format!("{config:?}");
140 assert!(!debug.contains("user:password"));
141 assert!(!debug.contains("secret"));
142 assert!(!debug.contains("account"));
143 assert!(!debug.contains("spacetime-secret"));
144 assert!(!debug.contains("storage-secret"));
145 assert!(!debug.contains("mail-secret"));
146 assert!(debug.contains("[REDACTED]"));
147 }
148}