Skip to main content

vane_core/config/
env.rs

1//! Typed accessors for `VANE_*` deployment-constant env vars
2//! (`spec/crates/core.md` § _Config layers_).
3//!
4//! The [`EnvReader`] trait abstracts the source so unit tests pass a
5//! `HashMap`-backed fake instead of mutating process-global state — Rust
6//! 1.95 marks `std::env::set_var` `unsafe` due to multi-thread races.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::Error;
11
12/// Reads a key → optional string value. The single production
13/// implementation, [`ProcessEnv`], delegates to `std::env::var`. Tests
14/// hand-roll a fake `EnvReader` to keep state local.
15pub trait EnvReader {
16	fn get(&self, key: &str) -> Option<String>;
17}
18
19/// Production [`EnvReader`] — reads from `std::env`.
20pub 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/// Typed snapshot of every `VANE_*` deployment constant the daemon
29/// reads at startup. Defaults match `spec/crates/core.md`
30/// § _Config layers_.
31///
32/// `config_dir` is **not** modeled as a field — the daemon's `--config`
33/// CLI arg is the single source of truth, and [`Env::from_reader`]
34/// takes that path explicitly so derived defaults (`wasm_dir`) follow
35/// it without an extra env var to keep in sync.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Env {
38	/// `VANE_WASM_DIR` — WASM plugin source directory scanned at boot.
39	/// Defaults to `<config_dir>/wasm` where `config_dir` is the
40	/// daemon's `--config` argument. See
41	/// `spec/crates/engine-wasm.md` § _Module lifecycle_.
42	pub wasm_dir: PathBuf,
43	/// `VANE_LOG_LEVEL` — `tracing-subscriber` filter directive
44	/// (default `"info"`). Honors the same syntax as `RUST_LOG`
45	/// (per-target overrides like `vane=debug,hyper=warn`). The
46	/// process env `RUST_LOG`, when set, takes precedence so
47	/// operators can override the file value ad-hoc.
48	pub log_level: String,
49	/// `VANE_BIND_IPV4` — listen on 0.0.0.0 for `:N` listen specs (default `true`).
50	pub bind_ipv4: bool,
51	/// `VANE_BIND_IPV6` — listen on `[::]` for `:N` listen specs (default `true`).
52	pub bind_ipv6: bool,
53	/// `VANE_SEC_MAX_HEADER_BYTES` — request-header size cap (default 65536).
54	pub sec_max_header_bytes: u32,
55	/// `VANE_SEC_MAX_HEADERS_COUNT` — request-header count cap (default 100).
56	pub sec_max_headers_count: u32,
57	/// `VANE_SEC_HEADER_TIMEOUT` — header-completion timeout, seconds (default 30).
58	pub sec_header_timeout_secs: u32,
59	/// `VANE_SEC_MAX_CONN_PER_IP` — per-IP concurrent-connection cap (default 100).
60	pub sec_max_conn_per_ip: u32,
61	/// `VANE_SEC_MAX_TOTAL_CONNS` — daemon-wide concurrent-connection cap (default 65536).
62	pub sec_max_total_conns: u32,
63	/// `VANE_BIND_MAX_ATTEMPTS` — bind-retry count per listener address (default 10).
64	pub bind_max_attempts: u32,
65	/// `VANE_BIND_BACKOFF_INITIAL_MS` — initial retry backoff in milliseconds (default 100).
66	pub bind_backoff_initial_ms: u32,
67	/// `VANE_BIND_BACKOFF_MAX_MS` — retry backoff cap in milliseconds (default 5000).
68	pub bind_backoff_max_ms: u32,
69	/// `VANE_FORCE_CANCEL_GRACE_SECS` — secondary grace window after `force_cancel` fires,
70	/// seconds (default 5). Applies to both SIGTERM drain and removed-listener reconcile.
71	pub force_cancel_grace_secs: u32,
72	/// `VANE_DRAIN_TIMEOUT_SECS` — in-flight connection drain budget for reload and SIGTERM,
73	/// seconds (default 30).
74	pub drain_timeout_secs: u32,
75	/// `VANE_BOOT_HEALTH_TIMEOUT_SECS` — budget for all listeners to flip `bind_ready`,
76	/// seconds (default 60). Partial bind (some bound, some failed) stays a warn.
77	pub boot_health_timeout_secs: u32,
78	/// `VANE_MGMT_UNIX` — management Unix socket path (default `/tmp/vaned.sock`).
79	pub mgmt_unix: PathBuf,
80	/// `VANE_MGMT_HTTP_PORT` — TCP port for the HTTP management transport.
81	/// `Some(3333)` by default; an explicit empty string disables the
82	/// transport (`None`). Matches `spec/crates/core.md`
83	/// § _Config layers_.
84	pub mgmt_http_port: Option<u16>,
85	/// `VANE_MGMT_HTTP_PUBLIC` — when truthy, bind the HTTP management
86	/// port on the wildcard address (`0.0.0.0` / `[::]`). When falsy
87	/// (default), bind on loopback. Mandatory pairing with
88	/// `mgmt_http_token` is enforced at daemon boot, not here.
89	pub mgmt_http_public: bool,
90	/// `VANE_MGMT_HTTP_TOKEN` — bearer token for the HTTP management
91	/// transport (`None` when unset or empty string).
92	pub mgmt_http_token: Option<String>,
93}
94
95impl Env {
96	/// Read from the actual process environment.
97	///
98	/// `config_dir` is the daemon's resolved `--config` path; it is
99	/// the basis for `wasm_dir`'s default when `VANE_WASM_DIR` is unset.
100	///
101	/// # Errors
102	/// Returns [`Error::compile`] when any `VANE_*` value fails its
103	/// type-specific parse (bool, u32, port).
104	pub fn from_process_env(config_dir: &Path) -> Result<Self, Error> {
105		Self::from_reader(&ProcessEnv, config_dir)
106	}
107
108	/// Read from any [`EnvReader`]. Primary entry point for unit tests.
109	///
110	/// # Errors
111	/// As [`Self::from_process_env`].
112	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
159/// Parse `VANE_MGMT_HTTP_PORT`. Unset → default `Some(3333)`; explicit
160/// empty string → `None` (transport disabled). Anything else parses as
161/// a `u16`.
162fn 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
173/// Boolean env-var parse used for `VANE_MGMT_HTTP_PUBLIC`. Truthy =
174/// `1` / `true` / `yes` / `on` (case-insensitive). Anything else,
175/// including unset / empty / `0` / `false` / `no` / `off`, is falsy.
176fn 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		// dotenvy may write `KEY=` with no value — that should not be a
240		// hard error; treat as unset.
241		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		// u32 cannot hold negative; ensure the error path fires cleanly.
283		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}