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)]
32#[non_exhaustive]
33pub enum LoadError {
34 NotFound(PathBuf),
36 Io { path: PathBuf, source: std::io::Error },
38 Parse { path: PathBuf, source: Box<toml::de::Error> },
42 Validation { path: PathBuf, source: ConfigError },
44 RemovedKey { path: PathBuf, key: &'static str, hint: &'static str },
47 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
78pub 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
90fn 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
104pub fn parse_and_validate(text: &str, path: &Path) -> Result<Config, LoadError> {
106 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
129pub 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 #[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 let p2 = resolve_path(&args(&[]), Some(String::new()));
172 assert_eq!(p2, PathBuf::from(DEFAULT_CONFIG_PATH));
173 }
174
175 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 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}