Skip to main content

waf_proxy/
config.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! External configuration loading: path resolution + load → parse → validate.
5//!
6//! Precedence for the config path (most explicit/ephemeral first):
7//!   1. CLI flag `--config <path>` / `--config=<path>` — per-invocation intent.
8//!   2. env var `WAF_CONFIG` — deployment-level (container/systemd/CI).
9//!   3. default `config.toml` — last resort.
10//!
11//! A missing file is ALWAYS fatal: a WAF must never start with implicit config
12//! (empty trusted_proxies, rate limit off, untuned thresholds) — the
13//! "looks-protected-but-isn't" failure mode. Errors print to stderr and the
14//! process exits non-zero; we never start partially configured.
15//!
16//! `parse_and_validate` (parse + semantic `Config::validate`) is the fs/CLI-free
17//! core, reused by hot reload (Pillar 3).
18
19use std::path::{Path, PathBuf};
20
21use waf_core::{Config, ConfigError};
22
23pub const DEFAULT_CONFIG_PATH: &str = "config.toml";
24pub const ENV_CONFIG: &str = "WAF_CONFIG";
25
26/// Why loading the configuration failed. Each variant maps to a distinct
27/// operator diagnosis (file vs syntax vs semantics).
28///
29/// `#[non_exhaustive]` (since 0.3, when `ModuleFactory` was added): new failure kinds can be
30/// added without a breaking change — external code matching this enum must carry a `_` arm.
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum LoadError {
34    /// The file does not exist — distinct from a present-but-wrong file.
35    NotFound(PathBuf),
36    /// The file exists but could not be read (permissions, etc.).
37    Io { path: PathBuf, source: std::io::Error },
38    /// TOML syntax error or a missing/incorrectly-typed required field
39    /// (serde reports e.g. "missing field `backend`"). Boxed: `toml::de::Error`
40    /// is large and would bloat every `Result` otherwise.
41    Parse { path: PathBuf, source: Box<toml::de::Error> },
42    /// Syntactically valid but semantically invalid (out-of-range, bad CIDR, …).
43    Validation { path: PathBuf, source: ConfigError },
44    /// A removed config key is still present — a clear migration error, never a
45    /// silent no-op. Carries the offending key and the migration hint.
46    RemovedKey { path: PathBuf, key: &'static str, hint: &'static str },
47    /// The config itself validated, but rebuilding the injected (embedder) modules failed
48    /// on reload (core 0.3 `ModuleFactory`) — e.g. an enterprise schema file became invalid
49    /// on disk. The reload is aborted and the last-good modules kept; carries the message.
50    ModuleFactory(String),
51}
52
53impl std::fmt::Display for LoadError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::NotFound(p) => write!(
57                f,
58                "config file not found at {}: provide --config <path>, set {ENV_CONFIG}, \
59                 or create {DEFAULT_CONFIG_PATH}",
60                p.display()
61            ),
62            Self::Io { path, source } =>
63                write!(f, "cannot read config file {}: {source}", path.display()),
64            Self::Parse { path, source } =>
65                write!(f, "invalid config in {}: {source}", path.display()),
66            Self::Validation { path, source } =>
67                write!(f, "invalid config in {}: {source}", path.display()),
68            Self::RemovedKey { path, key, hint } =>
69                write!(f, "invalid config in {}: `{key}` has been removed — {hint}", path.display()),
70            Self::ModuleFactory(msg) =>
71                write!(f, "config validated but rebuilding injected modules failed on reload: {msg}"),
72        }
73    }
74}
75
76impl std::error::Error for LoadError {}
77
78/// Resolve the config path by precedence: CLI flag > env var > default.
79/// `args` are the process args WITHOUT the program name; `env` is `WAF_CONFIG`.
80pub fn resolve_path(args: &[String], env: Option<String>) -> PathBuf {
81    if let Some(p) = cli_config(args) {
82        return PathBuf::from(p);
83    }
84    if let Some(e) = env.filter(|s| !s.is_empty()) {
85        return PathBuf::from(e);
86    }
87    PathBuf::from(DEFAULT_CONFIG_PATH)
88}
89
90/// Extract `--config <path>` or `--config=<path>` from the argument list.
91fn cli_config(args: &[String]) -> Option<String> {
92    let mut it = args.iter();
93    while let Some(arg) = it.next() {
94        if let Some(value) = arg.strip_prefix("--config=") {
95            return Some(value.to_string());
96        }
97        if arg == "--config" {
98            return it.next().cloned();
99        }
100    }
101    None
102}
103
104/// Parse + validate a TOML string. fs/CLI-free, so hot reload can reuse it.
105pub fn parse_and_validate(text: &str, path: &Path) -> Result<Config, LoadError> {
106    // Migration guard: `waf.fail_open` was removed in favour of [resilience].
107    // Catch a leftover key explicitly (serde would otherwise ignore it silently).
108    if let Ok(doc) = text.parse::<toml::Table>() {
109        if doc.get("waf").and_then(|w| w.as_table()).is_some_and(|w| w.contains_key("fail_open")) {
110            return Err(LoadError::RemovedKey {
111                path: path.to_path_buf(),
112                key: "waf.fail_open",
113                hint: "configure failure behaviour via the [resilience] section",
114            });
115        }
116    }
117
118    let config: Config = toml::from_str(text).map_err(|source| LoadError::Parse {
119        path: path.to_path_buf(),
120        source: Box::new(source),
121    })?;
122    config.validate().map_err(|source| LoadError::Validation {
123        path: path.to_path_buf(),
124        source,
125    })?;
126    Ok(config)
127}
128
129/// Full load pipeline: read file (missing → fatal) → parse → validate.
130pub fn load(path: &Path) -> Result<Config, LoadError> {
131    let text = std::fs::read_to_string(path).map_err(|e| match e.kind() {
132        std::io::ErrorKind::NotFound => LoadError::NotFound(path.to_path_buf()),
133        _ => LoadError::Io { path: path.to_path_buf(), source: e },
134    })?;
135    parse_and_validate(&text, path)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn args(v: &[&str]) -> Vec<String> {
143        v.iter().map(|s| s.to_string()).collect()
144    }
145
146    // ── path precedence ────────────────────────────────────────────────────────
147
148    #[test]
149    fn cli_flag_wins_over_env_and_default() {
150        let p = resolve_path(&args(&["--config", "cli.toml"]), Some("env.toml".to_string()));
151        assert_eq!(p, PathBuf::from("cli.toml"));
152    }
153
154    #[test]
155    fn cli_equals_form_supported() {
156        let p = resolve_path(&args(&["--config=cli.toml"]), Some("env.toml".to_string()));
157        assert_eq!(p, PathBuf::from("cli.toml"));
158    }
159
160    #[test]
161    fn env_wins_over_default_when_no_cli() {
162        let p = resolve_path(&args(&[]), Some("env.toml".to_string()));
163        assert_eq!(p, PathBuf::from("env.toml"));
164    }
165
166    #[test]
167    fn default_when_no_cli_no_env() {
168        let p = resolve_path(&args(&[]), None);
169        assert_eq!(p, PathBuf::from(DEFAULT_CONFIG_PATH));
170        // Empty env var is treated as unset.
171        let p2 = resolve_path(&args(&[]), Some(String::new()));
172        assert_eq!(p2, PathBuf::from(DEFAULT_CONFIG_PATH));
173    }
174
175    // ── load errors ─────────────────────────────────────────────────────────────
176
177    fn unique_tmp(name: &str) -> PathBuf {
178        let mut p = std::env::temp_dir();
179        let nanos = std::time::SystemTime::now()
180            .duration_since(std::time::UNIX_EPOCH)
181            .unwrap()
182            .as_nanos();
183        p.push(format!("waf-cfg-{name}-{nanos}.toml"));
184        p
185    }
186
187    const VALID_TOML: &str = r#"
188[proxy]
189listen = "127.0.0.1:8080"
190backend = "http://localhost:3000"
191
192[waf]
193mode = "detection-only"
194"#;
195
196    #[test]
197    fn missing_file_is_not_found() {
198        let path = unique_tmp("missing");
199        assert!(matches!(load(&path), Err(LoadError::NotFound(_))));
200    }
201
202    #[test]
203    fn valid_file_loads() {
204        let path = unique_tmp("valid");
205        std::fs::write(&path, VALID_TOML).unwrap();
206        let cfg = load(&path).expect("should load");
207        assert_eq!(cfg.proxy.backend, "http://localhost:3000");
208        std::fs::remove_file(&path).ok();
209    }
210
211    #[test]
212    fn malformed_toml_is_parse_error() {
213        let path = unique_tmp("malformed");
214        std::fs::write(&path, "this is = = not valid toml [[[").unwrap();
215        assert!(matches!(load(&path), Err(LoadError::Parse { .. })));
216        std::fs::remove_file(&path).ok();
217    }
218
219    #[test]
220    fn missing_required_field_is_parse_error_not_not_found() {
221        // File present but `backend` missing → serde "missing field", a Parse
222        // error, diagnostically distinct from a missing file.
223        let path = unique_tmp("missing-field");
224        std::fs::write(&path, "[proxy]\nlisten = \"127.0.0.1:8080\"\n[waf]\n").unwrap();
225        assert!(matches!(load(&path), Err(LoadError::Parse { .. })));
226        std::fs::remove_file(&path).ok();
227    }
228
229    #[test]
230    fn removed_fail_open_key_is_clear_migration_error() {
231        let path = unique_tmp("legacy-fail-open");
232        let toml = format!("{VALID_TOML}fail_open = true\n");
233        std::fs::write(&path, toml).unwrap();
234        match load(&path) {
235            Err(LoadError::RemovedKey { key: "waf.fail_open", .. }) => {}
236            other => panic!("expected RemovedKey for waf.fail_open, got {other:?}"),
237        }
238        std::fs::remove_file(&path).ok();
239    }
240
241    #[test]
242    fn semantically_invalid_is_validation_error() {
243        let path = unique_tmp("bad-value");
244        let toml = format!("{VALID_TOML}\n[network]\ntrusted_hops = 99\n");
245        std::fs::write(&path, toml).unwrap();
246        match load(&path) {
247            Err(LoadError::Validation { source: ConfigError::TrustedHopsOutOfRange(99), .. }) => {}
248            other => panic!("expected TrustedHopsOutOfRange, got {other:?}"),
249        }
250        std::fs::remove_file(&path).ok();
251    }
252
253    #[test]
254    fn illegal_cidr_is_validation_error() {
255        let path = unique_tmp("bad-cidr");
256        let toml = format!("{VALID_TOML}\n[network]\ntrusted_proxies = [\"10.0.0.0/8\", \"nonsense\"]\n");
257        std::fs::write(&path, toml).unwrap();
258        assert!(matches!(
259            load(&path),
260            Err(LoadError::Validation { source: ConfigError::InvalidCidr(_), .. })
261        ));
262        std::fs::remove_file(&path).ok();
263    }
264}