1use 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#[derive(Debug)]
29pub enum LoadError {
30 NotFound(PathBuf),
32 Io { path: PathBuf, source: std::io::Error },
34 Parse { path: PathBuf, source: Box<toml::de::Error> },
38 Validation { path: PathBuf, source: ConfigError },
40 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
68pub 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
80fn 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
94pub fn parse_and_validate(text: &str, path: &Path) -> Result<Config, LoadError> {
96 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
119pub 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 #[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 let p2 = resolve_path(&args(&[]), Some(String::new()));
162 assert_eq!(p2, PathBuf::from(DEFAULT_CONFIG_PATH));
163 }
164
165 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 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}