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#[derive(Debug)]
29pub enum LoadError {
30    /// The file does not exist — distinct from a present-but-wrong file.
31    NotFound(PathBuf),
32    /// The file exists but could not be read (permissions, etc.).
33    Io { path: PathBuf, source: std::io::Error },
34    /// TOML syntax error or a missing/incorrectly-typed required field
35    /// (serde reports e.g. "missing field `backend`"). Boxed: `toml::de::Error`
36    /// is large and would bloat every `Result` otherwise.
37    Parse { path: PathBuf, source: Box<toml::de::Error> },
38    /// Syntactically valid but semantically invalid (out-of-range, bad CIDR, …).
39    Validation { path: PathBuf, source: ConfigError },
40    /// A removed config key is still present — a clear migration error, never a
41    /// silent no-op. Carries the offending key and the migration hint.
42    RemovedKey { path: PathBuf, key: &'static str, hint: &'static str },
43}
44
45impl std::fmt::Display for LoadError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::NotFound(p) => write!(
49                f,
50                "config file not found at {}: provide --config <path>, set {ENV_CONFIG}, \
51                 or create {DEFAULT_CONFIG_PATH}",
52                p.display()
53            ),
54            Self::Io { path, source } =>
55                write!(f, "cannot read config file {}: {source}", path.display()),
56            Self::Parse { path, source } =>
57                write!(f, "invalid config in {}: {source}", path.display()),
58            Self::Validation { path, source } =>
59                write!(f, "invalid config in {}: {source}", path.display()),
60            Self::RemovedKey { path, key, hint } =>
61                write!(f, "invalid config in {}: `{key}` has been removed — {hint}", path.display()),
62        }
63    }
64}
65
66impl std::error::Error for LoadError {}
67
68/// Resolve the config path by precedence: CLI flag > env var > default.
69/// `args` are the process args WITHOUT the program name; `env` is `WAF_CONFIG`.
70pub fn resolve_path(args: &[String], env: Option<String>) -> PathBuf {
71    if let Some(p) = cli_config(args) {
72        return PathBuf::from(p);
73    }
74    if let Some(e) = env.filter(|s| !s.is_empty()) {
75        return PathBuf::from(e);
76    }
77    PathBuf::from(DEFAULT_CONFIG_PATH)
78}
79
80/// Extract `--config <path>` or `--config=<path>` from the argument list.
81fn cli_config(args: &[String]) -> Option<String> {
82    let mut it = args.iter();
83    while let Some(arg) = it.next() {
84        if let Some(value) = arg.strip_prefix("--config=") {
85            return Some(value.to_string());
86        }
87        if arg == "--config" {
88            return it.next().cloned();
89        }
90    }
91    None
92}
93
94/// Parse + validate a TOML string. fs/CLI-free, so hot reload can reuse it.
95pub fn parse_and_validate(text: &str, path: &Path) -> Result<Config, LoadError> {
96    // Migration guard: `waf.fail_open` was removed in favour of [resilience].
97    // Catch a leftover key explicitly (serde would otherwise ignore it silently).
98    if let Ok(doc) = text.parse::<toml::Table>() {
99        if doc.get("waf").and_then(|w| w.as_table()).is_some_and(|w| w.contains_key("fail_open")) {
100            return Err(LoadError::RemovedKey {
101                path: path.to_path_buf(),
102                key: "waf.fail_open",
103                hint: "configure failure behaviour via the [resilience] section",
104            });
105        }
106    }
107
108    let config: Config = toml::from_str(text).map_err(|source| LoadError::Parse {
109        path: path.to_path_buf(),
110        source: Box::new(source),
111    })?;
112    config.validate().map_err(|source| LoadError::Validation {
113        path: path.to_path_buf(),
114        source,
115    })?;
116    Ok(config)
117}
118
119/// Full load pipeline: read file (missing → fatal) → parse → validate.
120pub fn load(path: &Path) -> Result<Config, LoadError> {
121    let text = std::fs::read_to_string(path).map_err(|e| match e.kind() {
122        std::io::ErrorKind::NotFound => LoadError::NotFound(path.to_path_buf()),
123        _ => LoadError::Io { path: path.to_path_buf(), source: e },
124    })?;
125    parse_and_validate(&text, path)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn args(v: &[&str]) -> Vec<String> {
133        v.iter().map(|s| s.to_string()).collect()
134    }
135
136    // ── path precedence ────────────────────────────────────────────────────────
137
138    #[test]
139    fn cli_flag_wins_over_env_and_default() {
140        let p = resolve_path(&args(&["--config", "cli.toml"]), Some("env.toml".to_string()));
141        assert_eq!(p, PathBuf::from("cli.toml"));
142    }
143
144    #[test]
145    fn cli_equals_form_supported() {
146        let p = resolve_path(&args(&["--config=cli.toml"]), Some("env.toml".to_string()));
147        assert_eq!(p, PathBuf::from("cli.toml"));
148    }
149
150    #[test]
151    fn env_wins_over_default_when_no_cli() {
152        let p = resolve_path(&args(&[]), Some("env.toml".to_string()));
153        assert_eq!(p, PathBuf::from("env.toml"));
154    }
155
156    #[test]
157    fn default_when_no_cli_no_env() {
158        let p = resolve_path(&args(&[]), None);
159        assert_eq!(p, PathBuf::from(DEFAULT_CONFIG_PATH));
160        // Empty env var is treated as unset.
161        let p2 = resolve_path(&args(&[]), Some(String::new()));
162        assert_eq!(p2, PathBuf::from(DEFAULT_CONFIG_PATH));
163    }
164
165    // ── load errors ─────────────────────────────────────────────────────────────
166
167    fn unique_tmp(name: &str) -> PathBuf {
168        let mut p = std::env::temp_dir();
169        let nanos = std::time::SystemTime::now()
170            .duration_since(std::time::UNIX_EPOCH)
171            .unwrap()
172            .as_nanos();
173        p.push(format!("waf-cfg-{name}-{nanos}.toml"));
174        p
175    }
176
177    const VALID_TOML: &str = r#"
178[proxy]
179listen = "127.0.0.1:8080"
180backend = "http://localhost:3000"
181
182[waf]
183mode = "detection-only"
184"#;
185
186    #[test]
187    fn missing_file_is_not_found() {
188        let path = unique_tmp("missing");
189        assert!(matches!(load(&path), Err(LoadError::NotFound(_))));
190    }
191
192    #[test]
193    fn valid_file_loads() {
194        let path = unique_tmp("valid");
195        std::fs::write(&path, VALID_TOML).unwrap();
196        let cfg = load(&path).expect("should load");
197        assert_eq!(cfg.proxy.backend, "http://localhost:3000");
198        std::fs::remove_file(&path).ok();
199    }
200
201    #[test]
202    fn malformed_toml_is_parse_error() {
203        let path = unique_tmp("malformed");
204        std::fs::write(&path, "this is = = not valid toml [[[").unwrap();
205        assert!(matches!(load(&path), Err(LoadError::Parse { .. })));
206        std::fs::remove_file(&path).ok();
207    }
208
209    #[test]
210    fn missing_required_field_is_parse_error_not_not_found() {
211        // File present but `backend` missing → serde "missing field", a Parse
212        // error, diagnostically distinct from a missing file.
213        let path = unique_tmp("missing-field");
214        std::fs::write(&path, "[proxy]\nlisten = \"127.0.0.1:8080\"\n[waf]\n").unwrap();
215        assert!(matches!(load(&path), Err(LoadError::Parse { .. })));
216        std::fs::remove_file(&path).ok();
217    }
218
219    #[test]
220    fn removed_fail_open_key_is_clear_migration_error() {
221        let path = unique_tmp("legacy-fail-open");
222        let toml = format!("{VALID_TOML}fail_open = true\n");
223        std::fs::write(&path, toml).unwrap();
224        match load(&path) {
225            Err(LoadError::RemovedKey { key: "waf.fail_open", .. }) => {}
226            other => panic!("expected RemovedKey for waf.fail_open, got {other:?}"),
227        }
228        std::fs::remove_file(&path).ok();
229    }
230
231    #[test]
232    fn semantically_invalid_is_validation_error() {
233        let path = unique_tmp("bad-value");
234        let toml = format!("{VALID_TOML}\n[network]\ntrusted_hops = 99\n");
235        std::fs::write(&path, toml).unwrap();
236        match load(&path) {
237            Err(LoadError::Validation { source: ConfigError::TrustedHopsOutOfRange(99), .. }) => {}
238            other => panic!("expected TrustedHopsOutOfRange, got {other:?}"),
239        }
240        std::fs::remove_file(&path).ok();
241    }
242
243    #[test]
244    fn illegal_cidr_is_validation_error() {
245        let path = unique_tmp("bad-cidr");
246        let toml = format!("{VALID_TOML}\n[network]\ntrusted_proxies = [\"10.0.0.0/8\", \"nonsense\"]\n");
247        std::fs::write(&path, toml).unwrap();
248        assert!(matches!(
249            load(&path),
250            Err(LoadError::Validation { source: ConfigError::InvalidCidr(_), .. })
251        ));
252        std::fs::remove_file(&path).ok();
253    }
254}