1use std::path::{Path, PathBuf};
9
10use crate::error::Error;
11
12pub trait EnvReader {
16 fn get(&self, key: &str) -> Option<String>;
17}
18
19pub struct ProcessEnv;
21
22impl EnvReader for ProcessEnv {
23 fn get(&self, key: &str) -> Option<String> {
24 std::env::var(key).ok()
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Env {
38 pub wasm_dir: PathBuf,
43 pub log_level: String,
49 pub bind_ipv4: bool,
51 pub bind_ipv6: bool,
53 pub sec_max_header_bytes: u32,
55 pub sec_max_headers_count: u32,
57 pub sec_header_timeout_secs: u32,
59 pub sec_max_conn_per_ip: u32,
61 pub sec_max_total_conns: u32,
63 pub bind_max_attempts: u32,
65 pub bind_backoff_initial_ms: u32,
67 pub bind_backoff_max_ms: u32,
69 pub force_cancel_grace_secs: u32,
72 pub drain_timeout_secs: u32,
75 pub boot_health_timeout_secs: u32,
78 pub mgmt_unix: PathBuf,
80 pub mgmt_http_port: Option<u16>,
85 pub mgmt_http_public: bool,
90 pub mgmt_http_token: Option<String>,
93}
94
95impl Env {
96 pub fn from_process_env(config_dir: &Path) -> Result<Self, Error> {
105 Self::from_reader(&ProcessEnv, config_dir)
106 }
107
108 pub fn from_reader<R: EnvReader>(r: &R, config_dir: &Path) -> Result<Self, Error> {
113 let wasm_dir = r.get("VANE_WASM_DIR").map_or_else(|| config_dir.join("wasm"), PathBuf::from);
114 Ok(Self {
115 wasm_dir,
116 log_level: r
117 .get("VANE_LOG_LEVEL")
118 .filter(|s| !s.is_empty())
119 .unwrap_or_else(|| "info".to_string()),
120 bind_ipv4: parse_bool_default_true(r, "VANE_BIND_IPV4")?,
121 bind_ipv6: parse_bool_default_true(r, "VANE_BIND_IPV6")?,
122 sec_max_header_bytes: parse_u32_default(r, "VANE_SEC_MAX_HEADER_BYTES", 65_536)?,
123 sec_max_headers_count: parse_u32_default(r, "VANE_SEC_MAX_HEADERS_COUNT", 100)?,
124 sec_header_timeout_secs: parse_u32_default(r, "VANE_SEC_HEADER_TIMEOUT", 30)?,
125 sec_max_conn_per_ip: parse_u32_default(r, "VANE_SEC_MAX_CONN_PER_IP", 100)?,
126 sec_max_total_conns: parse_u32_default(r, "VANE_SEC_MAX_TOTAL_CONNS", 65_536)?,
127 bind_max_attempts: parse_u32_default(r, "VANE_BIND_MAX_ATTEMPTS", 10)?,
128 bind_backoff_initial_ms: parse_u32_default(r, "VANE_BIND_BACKOFF_INITIAL_MS", 100)?,
129 bind_backoff_max_ms: parse_u32_default(r, "VANE_BIND_BACKOFF_MAX_MS", 5_000)?,
130 force_cancel_grace_secs: parse_u32_default(r, "VANE_FORCE_CANCEL_GRACE_SECS", 5)?,
131 drain_timeout_secs: parse_u32_default(r, "VANE_DRAIN_TIMEOUT_SECS", 30)?,
132 boot_health_timeout_secs: parse_u32_default(r, "VANE_BOOT_HEALTH_TIMEOUT_SECS", 60)?,
133 mgmt_unix: r
134 .get("VANE_MGMT_UNIX")
135 .filter(|s| !s.is_empty())
136 .map_or_else(|| PathBuf::from("/tmp/vaned.sock"), PathBuf::from),
137 mgmt_http_port: parse_http_port(r)?,
138 mgmt_http_public: parse_truthy(r, "VANE_MGMT_HTTP_PUBLIC"),
139 mgmt_http_token: r.get("VANE_MGMT_HTTP_TOKEN").filter(|s| !s.is_empty()),
140 })
141 }
142}
143
144fn parse_bool_default_true<R: EnvReader>(r: &R, key: &str) -> Result<bool, Error> {
145 match r.get(key).as_deref() {
146 None | Some("" | "1") => Ok(true),
147 Some("0") => Ok(false),
148 Some(other) => Err(Error::compile(format!("{key} must be \"0\" or \"1\", got {other:?}"))),
149 }
150}
151
152fn parse_u32_default<R: EnvReader>(r: &R, key: &str, default: u32) -> Result<u32, Error> {
153 match r.get(key).filter(|s| !s.is_empty()) {
154 None => Ok(default),
155 Some(s) => s.parse::<u32>().map_err(|e| Error::compile(format!("{key}: {e} ({s:?})"))),
156 }
157}
158
159fn parse_http_port<R: EnvReader>(r: &R) -> Result<Option<u16>, Error> {
163 match r.get("VANE_MGMT_HTTP_PORT").as_deref() {
164 None => Ok(Some(3333)),
165 Some("") => Ok(None),
166 Some(s) => s
167 .parse::<u16>()
168 .map(Some)
169 .map_err(|e| Error::compile(format!("VANE_MGMT_HTTP_PORT: {e} ({s:?})"))),
170 }
171}
172
173fn parse_truthy<R: EnvReader>(r: &R, key: &str) -> bool {
177 matches!(r.get(key).map(|s| s.to_ascii_lowercase()).as_deref(), Some("1" | "true" | "yes" | "on"),)
178}
179
180#[cfg(test)]
181mod tests {
182 use std::collections::HashMap;
183
184 use super::*;
185
186 struct FakeEnv(HashMap<&'static str, &'static str>);
187
188 impl FakeEnv {
189 fn empty() -> Self {
190 Self(HashMap::new())
191 }
192
193 fn with(pairs: &[(&'static str, &'static str)]) -> Self {
194 Self(pairs.iter().copied().collect())
195 }
196 }
197
198 impl EnvReader for FakeEnv {
199 fn get(&self, key: &str) -> Option<String> {
200 self.0.get(key).map(|s| (*s).to_string())
201 }
202 }
203
204 fn cfg() -> PathBuf {
205 PathBuf::from("/etc/vaned")
206 }
207
208 #[test]
209 fn env_defaults_when_all_unset() {
210 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
211 assert_eq!(env.log_level, "info");
212 assert!(env.bind_ipv4);
213 assert!(env.bind_ipv6);
214 assert_eq!(env.sec_max_header_bytes, 65_536);
215 assert_eq!(env.sec_max_headers_count, 100);
216 assert_eq!(env.sec_header_timeout_secs, 30);
217 assert_eq!(env.sec_max_conn_per_ip, 100);
218 assert_eq!(env.sec_max_total_conns, 65_536);
219 assert_eq!(env.mgmt_unix, PathBuf::from("/tmp/vaned.sock"));
220 assert_eq!(env.mgmt_http_port, Some(3333));
221 assert!(!env.mgmt_http_public);
222 assert!(env.mgmt_http_token.is_none());
223 }
224
225 #[test]
226 fn env_bind_ipv4_zero_yields_false() {
227 let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "0")]), &cfg()).expect("ok");
228 assert!(!env.bind_ipv4);
229 }
230
231 #[test]
232 fn env_bind_ipv4_one_yields_true() {
233 let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "1")]), &cfg()).expect("ok");
234 assert!(env.bind_ipv4);
235 }
236
237 #[test]
238 fn env_bind_ipv4_empty_string_falls_back_to_default() {
239 let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "")]), &cfg()).expect("ok");
242 assert!(env.bind_ipv4, "empty string falls back to default true");
243 }
244
245 #[test]
246 fn env_bind_ipv4_invalid_returns_compile_error_naming_var() {
247 let err =
248 Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "yes")]), &cfg()).expect_err("invalid");
249 let msg = err.to_string();
250 assert!(msg.contains("VANE_BIND_IPV4"), "error names the var: {msg}");
251 assert!(msg.contains("\"yes\""), "error quotes the offending value: {msg}");
252 }
253
254 #[test]
255 fn env_sec_integers_parse() {
256 let env = Env::from_reader(
257 &FakeEnv::with(&[
258 ("VANE_SEC_MAX_HEADER_BYTES", "32768"),
259 ("VANE_SEC_MAX_HEADERS_COUNT", "64"),
260 ("VANE_SEC_HEADER_TIMEOUT", "10"),
261 ("VANE_SEC_MAX_CONN_PER_IP", "500"),
262 ]),
263 &cfg(),
264 )
265 .expect("ok");
266 assert_eq!(env.sec_max_header_bytes, 32_768);
267 assert_eq!(env.sec_max_headers_count, 64);
268 assert_eq!(env.sec_header_timeout_secs, 10);
269 assert_eq!(env.sec_max_conn_per_ip, 500);
270 }
271
272 #[test]
273 fn env_sec_invalid_integer_errors() {
274 let err = Env::from_reader(&FakeEnv::with(&[("VANE_SEC_MAX_HEADER_BYTES", "huge")]), &cfg())
275 .expect_err("non-int rejected");
276 let msg = err.to_string();
277 assert!(msg.contains("VANE_SEC_MAX_HEADER_BYTES"), "{msg}");
278 }
279
280 #[test]
281 fn env_sec_negative_integer_errors() {
282 let err = Env::from_reader(&FakeEnv::with(&[("VANE_SEC_MAX_CONN_PER_IP", "-1")]), &cfg())
284 .expect_err("negative rejected");
285 assert!(err.to_string().contains("VANE_SEC_MAX_CONN_PER_IP"));
286 }
287
288 #[test]
289 fn env_mgmt_http_port_default_is_3333() {
290 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
291 assert_eq!(env.mgmt_http_port, Some(3333));
292 }
293
294 #[test]
295 fn env_mgmt_http_port_empty_string_disables_transport() {
296 let env = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PORT", "")]), &cfg()).expect("ok");
297 assert_eq!(env.mgmt_http_port, None);
298 }
299
300 #[test]
301 fn env_mgmt_http_port_explicit_value_parses() {
302 let env =
303 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PORT", "9000")]), &cfg()).expect("ok");
304 assert_eq!(env.mgmt_http_port, Some(9000));
305 }
306
307 #[test]
308 fn env_mgmt_http_port_invalid_errors() {
309 let err = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PORT", "nope")]), &cfg())
310 .expect_err("bad port");
311 let msg = err.to_string();
312 assert!(msg.contains("VANE_MGMT_HTTP_PORT"), "{msg}");
313 assert!(msg.contains("\"nope\""), "{msg}");
314 }
315
316 #[test]
317 fn env_mgmt_http_public_truthy_values() {
318 for v in ["1", "true", "TRUE", "Yes", "on"] {
319 let env =
320 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PUBLIC", v)]), &cfg()).expect("ok");
321 assert!(env.mgmt_http_public, "{v} should be truthy");
322 }
323 }
324
325 #[test]
326 fn env_mgmt_http_public_falsy_values() {
327 for v in ["", "0", "false", "no", "off"] {
328 let env =
329 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_PUBLIC", v)]), &cfg()).expect("ok");
330 assert!(!env.mgmt_http_public, "{v} should be falsy");
331 }
332 }
333
334 #[test]
335 fn env_mgmt_http_token_empty_string_yields_none() {
336 let env =
337 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_TOKEN", "")]), &cfg()).expect("ok");
338 assert!(env.mgmt_http_token.is_none());
339 }
340
341 #[test]
342 fn env_mgmt_http_token_value_passes_through() {
343 let env =
344 Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_TOKEN", "hunter2")]), &cfg()).expect("ok");
345 assert_eq!(env.mgmt_http_token.as_deref(), Some("hunter2"));
346 }
347
348 #[test]
349 fn env_mgmt_unix_default_path() {
350 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
351 assert_eq!(env.mgmt_unix, PathBuf::from("/tmp/vaned.sock"));
352 }
353
354 #[test]
355 fn env_mgmt_unix_override() {
356 let env = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_UNIX", "/run/vane.sock")]), &cfg())
357 .expect("ok");
358 assert_eq!(env.mgmt_unix, PathBuf::from("/run/vane.sock"));
359 }
360
361 #[test]
362 fn env_log_level_passes_through_verbatim() {
363 for level in ["debug", "warn", "trace", "vane=info,hyper=warn"] {
364 let env = Env::from_reader(&FakeEnv::with(&[("VANE_LOG_LEVEL", level)]), &cfg()).expect("ok");
365 assert_eq!(env.log_level, level);
366 }
367 }
368
369 #[test]
370 fn env_wasm_dir_defaults_to_clap_config_dir_subdir() {
371 let env = Env::from_reader(&FakeEnv::empty(), &cfg()).expect("defaults");
372 assert_eq!(env.wasm_dir, PathBuf::from("/etc/vaned/wasm"));
373
374 let env = Env::from_reader(&FakeEnv::empty(), &PathBuf::from("/srv/vane/etc"))
375 .expect("custom config_dir");
376 assert_eq!(
377 env.wasm_dir,
378 PathBuf::from("/srv/vane/etc/wasm"),
379 "default tracks the supplied config_dir",
380 );
381 }
382
383 #[test]
384 fn env_wasm_dir_explicit_override_wins() {
385 let env =
386 Env::from_reader(&FakeEnv::with(&[("VANE_WASM_DIR", "/var/lib/vane/plugins")]), &cfg())
387 .expect("override");
388 assert_eq!(env.wasm_dir, PathBuf::from("/var/lib/vane/plugins"));
389 }
390}