http_smtp_rele/config/
validate.rs1use super::*;
6
7impl Default for RateLimitConfig {
8 fn default() -> Self {
9 Self {
10 global_per_min: default_global_per_min(),
11 per_ip_per_min: default_per_ip_per_min(),
12 per_key_per_min: default_per_key_per_min(),
13 global_burst: default_global_burst(),
14 per_ip_burst: default_per_ip_burst(),
15 per_key_burst: default_per_key_burst(),
16 ip_table_size: default_ip_table_size(),
17 burst_size: 0,
18 }
19 }
20}
21
22impl Default for LoggingConfig {
23 fn default() -> Self {
24 Self {
25 format: default_log_format(),
26 level: default_log_level(),
27 mask_recipient: false,
28 }
29 }
30}
31
32pub fn load_from_str(toml_str: &str) -> Result<AppConfig, ConfigError> {
37 let config: AppConfig = toml::from_str(toml_str)?;
38 validate_config(&config)?;
39 Ok(config)
40}
41
42pub fn load(path: &Path) -> Result<AppConfig, ConfigError> {
43 let text = std::fs::read_to_string(path)?;
44 let config: AppConfig = toml::from_str(&text)?;
45 validate_config(&config)?;
46 Ok(config)
47}
48
49pub fn validate_config(config: &AppConfig) -> Result<(), ConfigError> {
50 config
52 .server
53 .bind_address
54 .parse::<std::net::SocketAddr>()
55 .map_err(|_| ConfigError::InvalidBindAddress)?;
56
57 config
59 .mail
60 .default_from
61 .parse::<Address>()
62 .map_err(|_| ConfigError::InvalidDefaultFrom)?;
63
64 for cidr in config.security.trusted_source_cidrs.iter()
68 .chain(config.security.allowed_source_cidrs.iter())
69 {
70 cidr.parse::<ipnet::IpNet>()
71 .map_err(|_| ConfigError::InvalidCidr(cidr.clone()))?;
72 }
73
74 if config.smtp.port == 0 {
76 return Err(ConfigError::InvalidSmtpPort);
77 }
78
79 match config.smtp.tls.as_str() {
81 "none" | "starttls" | "tls" => {}
82 other => return Err(ConfigError::Validation(
83 format!("smtp.tls must be \"none\", \"starttls\", or \"tls\"; got \"{other}\"")
84 )),
85 }
86
87 match (&config.smtp.auth_user, &config.smtp.auth_password) {
89 (Some(_), None) | (None, Some(_)) => {
90 return Err(ConfigError::Validation(
91 "smtp.auth_user and smtp.auth_password must both be set or both absent".into(),
92 ));
93 }
94 _ => {}
95 }
96
97 match (&config.server.tls_cert, &config.server.tls_key) {
99 (Some(_), None) | (None, Some(_)) => {
100 return Err(ConfigError::Validation(
101 "server.tls_cert and server.tls_key must both be set or both be absent".into()
102 ));
103 }
104 (Some(_), Some(_)) => {
105 #[cfg(not(feature = "tls"))]
106 return Err(ConfigError::Validation(
107 "server.tls_cert/tls_key is configured but TLS is not available in this build. Rebuild with: cargo build --features tls".into()
108 ));
109 }
110 (None, None) => {}
111 }
112
113 if config.status.enabled {
115 if config.status.store == "sqlite" {
116 if config.status.db_path.is_none() {
117 return Err(ConfigError::Validation(
118 "status.db_path is required when status.store = \"sqlite\"".into()
119 ));
120 }
121 #[cfg(not(feature = "sqlite"))]
122 return Err(ConfigError::Validation(
123 "status.store = \"sqlite\" is not available in this build. Rebuild with: cargo build --features sqlite".into()
124 ));
125 } else if config.status.store == "redis" {
126 if config.status.redis_url.is_none() {
127 return Err(ConfigError::Validation(
128 "status.redis_url is required when status.store = \"redis\"".into()
129 ));
130 }
131 #[cfg(not(feature = "redis"))]
132 return Err(ConfigError::Validation(
133 "status.store = \"redis\" is not available in this build. Rebuild with: cargo build --features redis".into()
134 ));
135 } else if !matches!(config.status.store.as_str(), "memory") {
136 return Err(ConfigError::Validation(
137 format!("status.store must be \"memory\", \"sqlite\", or \"redis\"; got \"{}\""
138 , config.status.store)
139 ));
140 }
141 } if config.smtp.mode == "pipe"
145 && (config.smtp.auth_user.is_some() || config.smtp.auth_password.is_some())
146 {
147 return Err(ConfigError::Validation(
148 r#"smtp.auth_user/auth_password are not applicable when smtp.mode = "pipe""#.into(),
149 ));
150 }
151
152 if config.rate_limit.global_per_min == 0 || config.rate_limit.per_ip_per_min == 0 {
154 return Err(ConfigError::InvalidRateLimit);
155 }
156
157 let valid_levels = ["trace", "debug", "info", "warn", "error"];
159 if !valid_levels.contains(&config.logging.level.as_str()) {
160 return Err(ConfigError::InvalidLogLevel);
161 }
162
163 let valid_formats = ["text", "json"];
165 if !valid_formats.contains(&config.logging.format.as_str()) {
166 return Err(ConfigError::InvalidLogFormat);
167 }
168
169 const MIN_SECRET_LEN: usize = 32;
171 const BLOCKED: &[&str] = &[
172 "your-secret-here", "generate-with-openssl-rand-base64-32",
173 "changeme", "secret", "password", "example-secret", "replace-me",
174 ];
175 for key in &config.security.api_keys {
176 let s = key.secret.expose();
177 if s.len() < MIN_SECRET_LEN {
178 return Err(ConfigError::Validation(format!(
179 "api_keys[{}].secret: minimum {} bytes required (got {}). Generate with: openssl rand -base64 32",
180 key.id, MIN_SECRET_LEN, s.len()
181 )));
182 }
183 if BLOCKED.iter().any(|b| s.contains(b)) {
184 return Err(ConfigError::Validation(format!(
185 "api_keys[{}].secret: placeholder value detected. Replace with: openssl rand -base64 32", key.id
186 )));
187 }
188 }
189
190 if config.security.api_keys.is_empty() {
192 return Err(ConfigError::NoApiKeys);
193 }
194 if !config.security.api_keys.iter().any(|k| k.enabled) {
195 return Err(ConfigError::NoEnabledApiKeys);
196 }
197
198 Ok(())
199}
200
201#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn minimal_config_str() -> String {
210 r#"
212[server]
213bind_address = "127.0.0.1:8080"
214
215[[security.api_keys]]
216id = "test"
217secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
218enabled = true
219
220[rate_limit]
221
222[mail]
223default_from = "noreply@example.com"
224
225[smtp]
226
227[logging]
228"#
229 .into()
230 }
231
232 #[test]
233 fn valid_config_parses() {
234 let config: AppConfig = toml::from_str(&minimal_config_str()).unwrap();
235 assert!(validate_config(&config).is_ok());
236 }
237
238 #[test]
239 fn invalid_bind_address() {
240 let text = minimal_config_str().replace("127.0.0.1:8080", "notanaddress");
241 let config: AppConfig = toml::from_str(&text).unwrap();
242 assert!(matches!(validate_config(&config), Err(ConfigError::InvalidBindAddress)));
243 }
244
245 #[test]
246 fn invalid_default_from() {
247 let text = minimal_config_str().replace("noreply@example.com", "notanemail");
248 let config: AppConfig = toml::from_str(&text).unwrap();
249 assert!(matches!(validate_config(&config), Err(ConfigError::InvalidDefaultFrom)));
250 }
251
252 #[test]
253 fn secret_string_is_redacted_in_debug() {
254 let s = SecretString::new("very-secret");
255 assert!(!format!("{:?}", s).contains("very-secret"));
256 assert!(!format!("{}", s).contains("very-secret"));
257 assert_eq!(s.expose(), "very-secret");
258 }
259
260 #[test]
261 fn defaults_are_sensible() {
262 let config: AppConfig = toml::from_str(&minimal_config_str()).unwrap();
263 assert_eq!(config.server.max_request_body_bytes, 1_048_576);
264 assert_eq!(config.smtp.port, 25);
265 assert_eq!(config.rate_limit.global_per_min, 60);
266 assert_eq!(config.logging.format, "text");
267 }
268}
269