Skip to main content

http_smtp_rele/config/
validate.rs

1//! Configuration validation logic.
2//!
3//! Separated from type definitions to keep `config.rs` focused on the schema.
4
5use 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
32// ---------------------------------------------------------------------------
33// Load and validate
34// ---------------------------------------------------------------------------
35
36pub 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    // bind_address
51    config
52        .server
53        .bind_address
54        .parse::<std::net::SocketAddr>()
55        .map_err(|_| ConfigError::InvalidBindAddress)?;
56
57    // default_from
58    config
59        .mail
60        .default_from
61        .parse::<Address>()
62        .map_err(|_| ConfigError::InvalidDefaultFrom)?;
63
64    // API keys
65
66    // CIDRs — validate both lists
67    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    // SMTP port
75    if config.smtp.port == 0 {
76        return Err(ConfigError::InvalidSmtpPort);
77    }
78
79    // SMTP TLS mode
80    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    // SMTP AUTH: both user and password must be set or both absent
88    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    // TLS: cert and key must both be set or both absent (RFC 712)
98    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    // Status store validation — only when status tracking is enabled (RFC 816)
114    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    } // status.enabled guard (RFC 816)
142
143    // Pipe mode: auth credentials are not applicable
144    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    // Rate limits
153    if config.rate_limit.global_per_min == 0 || config.rate_limit.per_ip_per_min == 0 {
154        return Err(ConfigError::InvalidRateLimit);
155    }
156
157    // Log level
158    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    // Log format
164    let valid_formats = ["text", "json"];
165    if !valid_formats.contains(&config.logging.format.as_str()) {
166        return Err(ConfigError::InvalidLogFormat);
167    }
168
169    // RFC 824: API key secret quality enforcement
170    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    // Ensure at least one key is configured and enabled.
191    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// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn minimal_config_str() -> String {
210        // Secret is exactly 32+ bytes: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" (32 'a's)
211        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