Skip to main content

vane_core/config/
env.rs

1//! Typed accessors for `VANE_*` deployment-constant env vars
2//! (`spec/architecture/09-config.md` § _Three-layer configuration_).
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::net::SocketAddr;
9use std::path::PathBuf;
10
11use crate::error::Error;
12
13/// Reads a key → optional string value. The single production
14/// implementation, [`ProcessEnv`], delegates to `std::env::var`. Tests
15/// hand-roll a fake `EnvReader` to keep state local.
16pub trait EnvReader {
17	fn get(&self, key: &str) -> Option<String>;
18}
19
20/// Production [`EnvReader`] — reads from `std::env`.
21pub struct ProcessEnv;
22
23impl EnvReader for ProcessEnv {
24	fn get(&self, key: &str) -> Option<String> {
25		std::env::var(key).ok()
26	}
27}
28
29/// Typed snapshot of every `VANE_*` deployment constant the daemon
30/// reads at startup. Defaults match `spec/architecture/09-config.md`
31/// § _Three-layer configuration_.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Env {
34	/// `VANE_DATA_DIR` — daemon working data root (default `/var/lib/vaned`).
35	pub data_dir: PathBuf,
36	/// `VANE_CONFIG_DIR` — config-tree root (default `/etc/vaned`).
37	pub config_dir: PathBuf,
38	/// `VANE_LOG_LEVEL` — passed through to the tracing subscriber
39	/// verbatim (default `"info"`).
40	pub log_level: String,
41	/// `VANE_BIND_IPV4` — listen on 0.0.0.0 for `:N` listen specs (default `true`).
42	pub bind_ipv4: bool,
43	/// `VANE_BIND_IPV6` — listen on `[::]` for `:N` listen specs (default `true`).
44	pub bind_ipv6: bool,
45	/// `VANE_SEC_MAX_HEADER_BYTES` — request-header size cap (default 65536).
46	pub sec_max_header_bytes: u32,
47	/// `VANE_SEC_MAX_HEADERS_COUNT` — request-header count cap (default 100).
48	pub sec_max_headers_count: u32,
49	/// `VANE_SEC_HEADER_TIMEOUT` — header-completion timeout, seconds (default 30).
50	pub sec_header_timeout_secs: u32,
51	/// `VANE_SEC_MAX_CONN_PER_IP` — per-IP concurrent-connection cap (default 100).
52	pub sec_max_conn_per_ip: u32,
53	/// `VANE_SEC_MAX_TOTAL_CONNS` — daemon-wide concurrent-connection cap (default 65536).
54	pub sec_max_total_conns: u32,
55	/// `VANE_BIND_MAX_ATTEMPTS` — bind-retry count per listener address (default 10).
56	pub bind_max_attempts: u32,
57	/// `VANE_BIND_BACKOFF_INITIAL_MS` — initial retry backoff in milliseconds (default 100).
58	pub bind_backoff_initial_ms: u32,
59	/// `VANE_BIND_BACKOFF_MAX_MS` — retry backoff cap in milliseconds (default 5000).
60	pub bind_backoff_max_ms: u32,
61	/// `VANE_FORCE_CANCEL_GRACE_SECS` — secondary grace window after `force_cancel` fires,
62	/// seconds (default 5). Applies to both SIGTERM drain and removed-listener reconcile.
63	pub force_cancel_grace_secs: u32,
64	/// `VANE_DRAIN_TIMEOUT_SECS` — in-flight connection drain budget for reload and SIGTERM,
65	/// seconds (default 30).
66	pub drain_timeout_secs: u32,
67	/// `VANE_BOOT_HEALTH_TIMEOUT_SECS` — budget for all listeners to flip `bind_ready`,
68	/// seconds (default 60). Partial bind (some bound, some failed) stays a warn.
69	pub boot_health_timeout_secs: u32,
70	/// `VANE_MGMT_UNIX` — management Unix socket path (default `/var/run/vaned.sock`).
71	pub mgmt_unix: PathBuf,
72	/// `VANE_MGMT_HTTP_BIND` — optional HTTP management endpoint (`None`
73	/// when unset or empty string; otherwise must parse as `SocketAddr`).
74	pub mgmt_http_bind: Option<SocketAddr>,
75	/// `VANE_MGMT_HTTP_TOKEN` — bearer token for `mgmt_http_bind` (`None`
76	/// when unset or empty string).
77	pub mgmt_http_token: Option<String>,
78}
79
80impl Env {
81	/// Read from the actual process environment.
82	///
83	/// # Errors
84	/// Returns [`Error::compile`] when any `VANE_*` value fails its
85	/// type-specific parse (bool, u32, `SocketAddr`).
86	pub fn from_process_env() -> Result<Self, Error> {
87		Self::from_reader(&ProcessEnv)
88	}
89
90	/// Read from any [`EnvReader`]. Primary entry point for unit tests.
91	///
92	/// # Errors
93	/// As [`Self::from_process_env`].
94	pub fn from_reader<R: EnvReader>(r: &R) -> Result<Self, Error> {
95		Ok(Self {
96			data_dir: r
97				.get("VANE_DATA_DIR")
98				.map_or_else(|| PathBuf::from("/var/lib/vaned"), PathBuf::from),
99			config_dir: r
100				.get("VANE_CONFIG_DIR")
101				.map_or_else(|| PathBuf::from("/etc/vaned"), PathBuf::from),
102			log_level: r.get("VANE_LOG_LEVEL").unwrap_or_else(|| "info".to_string()),
103			bind_ipv4: parse_bool_default_true(r, "VANE_BIND_IPV4")?,
104			bind_ipv6: parse_bool_default_true(r, "VANE_BIND_IPV6")?,
105			sec_max_header_bytes: parse_u32_default(r, "VANE_SEC_MAX_HEADER_BYTES", 65_536)?,
106			sec_max_headers_count: parse_u32_default(r, "VANE_SEC_MAX_HEADERS_COUNT", 100)?,
107			sec_header_timeout_secs: parse_u32_default(r, "VANE_SEC_HEADER_TIMEOUT", 30)?,
108			sec_max_conn_per_ip: parse_u32_default(r, "VANE_SEC_MAX_CONN_PER_IP", 100)?,
109			sec_max_total_conns: parse_u32_default(r, "VANE_SEC_MAX_TOTAL_CONNS", 65_536)?,
110			bind_max_attempts: parse_u32_default(r, "VANE_BIND_MAX_ATTEMPTS", 10)?,
111			bind_backoff_initial_ms: parse_u32_default(r, "VANE_BIND_BACKOFF_INITIAL_MS", 100)?,
112			bind_backoff_max_ms: parse_u32_default(r, "VANE_BIND_BACKOFF_MAX_MS", 5_000)?,
113			force_cancel_grace_secs: parse_u32_default(r, "VANE_FORCE_CANCEL_GRACE_SECS", 5)?,
114			drain_timeout_secs: parse_u32_default(r, "VANE_DRAIN_TIMEOUT_SECS", 30)?,
115			boot_health_timeout_secs: parse_u32_default(r, "VANE_BOOT_HEALTH_TIMEOUT_SECS", 60)?,
116			mgmt_unix: r
117				.get("VANE_MGMT_UNIX")
118				.map_or_else(|| PathBuf::from("/var/run/vaned.sock"), PathBuf::from),
119			mgmt_http_bind: parse_socket_addr_optional(r, "VANE_MGMT_HTTP_BIND")?,
120			mgmt_http_token: r.get("VANE_MGMT_HTTP_TOKEN").filter(|s| !s.is_empty()),
121		})
122	}
123}
124
125fn parse_bool_default_true<R: EnvReader>(r: &R, key: &str) -> Result<bool, Error> {
126	match r.get(key).as_deref() {
127		None | Some("" | "1") => Ok(true),
128		Some("0") => Ok(false),
129		Some(other) => Err(Error::compile(format!("{key} must be \"0\" or \"1\", got {other:?}"))),
130	}
131}
132
133fn parse_u32_default<R: EnvReader>(r: &R, key: &str, default: u32) -> Result<u32, Error> {
134	match r.get(key).filter(|s| !s.is_empty()) {
135		None => Ok(default),
136		Some(s) => s.parse::<u32>().map_err(|e| Error::compile(format!("{key}: {e} ({s:?})"))),
137	}
138}
139
140fn parse_socket_addr_optional<R: EnvReader>(r: &R, key: &str) -> Result<Option<SocketAddr>, Error> {
141	match r.get(key).filter(|s| !s.is_empty()) {
142		None => Ok(None),
143		Some(s) => s
144			.parse::<SocketAddr>()
145			.map(Some)
146			.map_err(|e| Error::compile(format!("{key}: invalid SocketAddr {s:?}: {e}"))),
147	}
148}
149
150#[cfg(test)]
151mod tests {
152	use std::collections::HashMap;
153
154	use super::*;
155
156	struct FakeEnv(HashMap<&'static str, &'static str>);
157
158	impl FakeEnv {
159		fn empty() -> Self {
160			Self(HashMap::new())
161		}
162
163		fn with(pairs: &[(&'static str, &'static str)]) -> Self {
164			Self(pairs.iter().copied().collect())
165		}
166	}
167
168	impl EnvReader for FakeEnv {
169		fn get(&self, key: &str) -> Option<String> {
170			self.0.get(key).map(|s| (*s).to_string())
171		}
172	}
173
174	#[test]
175	fn env_defaults_when_all_unset() {
176		let env = Env::from_reader(&FakeEnv::empty()).expect("defaults");
177		assert_eq!(env.data_dir, PathBuf::from("/var/lib/vaned"));
178		assert_eq!(env.config_dir, PathBuf::from("/etc/vaned"));
179		assert_eq!(env.log_level, "info");
180		assert!(env.bind_ipv4);
181		assert!(env.bind_ipv6);
182		assert_eq!(env.sec_max_header_bytes, 65_536);
183		assert_eq!(env.sec_max_headers_count, 100);
184		assert_eq!(env.sec_header_timeout_secs, 30);
185		assert_eq!(env.sec_max_conn_per_ip, 100);
186		assert_eq!(env.sec_max_total_conns, 65_536);
187		assert_eq!(env.mgmt_unix, PathBuf::from("/var/run/vaned.sock"));
188		assert!(env.mgmt_http_bind.is_none());
189		assert!(env.mgmt_http_token.is_none());
190	}
191
192	#[test]
193	fn env_bind_ipv4_zero_yields_false() {
194		let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "0")])).expect("ok");
195		assert!(!env.bind_ipv4);
196	}
197
198	#[test]
199	fn env_bind_ipv4_one_yields_true() {
200		let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "1")])).expect("ok");
201		assert!(env.bind_ipv4);
202	}
203
204	#[test]
205	fn env_bind_ipv4_empty_string_falls_back_to_default() {
206		// dotenvy may write `KEY=` with no value — that should not be a
207		// hard error; treat as unset.
208		let env = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "")])).expect("ok");
209		assert!(env.bind_ipv4, "empty string falls back to default true");
210	}
211
212	#[test]
213	fn env_bind_ipv4_invalid_returns_compile_error_naming_var() {
214		let err = Env::from_reader(&FakeEnv::with(&[("VANE_BIND_IPV4", "yes")])).expect_err("invalid");
215		let msg = err.to_string();
216		assert!(msg.contains("VANE_BIND_IPV4"), "error names the var: {msg}");
217		assert!(msg.contains("\"yes\""), "error quotes the offending value: {msg}");
218	}
219
220	#[test]
221	fn env_sec_integers_parse() {
222		let env = Env::from_reader(&FakeEnv::with(&[
223			("VANE_SEC_MAX_HEADER_BYTES", "32768"),
224			("VANE_SEC_MAX_HEADERS_COUNT", "64"),
225			("VANE_SEC_HEADER_TIMEOUT", "10"),
226			("VANE_SEC_MAX_CONN_PER_IP", "500"),
227		]))
228		.expect("ok");
229		assert_eq!(env.sec_max_header_bytes, 32_768);
230		assert_eq!(env.sec_max_headers_count, 64);
231		assert_eq!(env.sec_header_timeout_secs, 10);
232		assert_eq!(env.sec_max_conn_per_ip, 500);
233	}
234
235	#[test]
236	fn env_sec_invalid_integer_errors() {
237		let err = Env::from_reader(&FakeEnv::with(&[("VANE_SEC_MAX_HEADER_BYTES", "huge")]))
238			.expect_err("non-int rejected");
239		let msg = err.to_string();
240		assert!(msg.contains("VANE_SEC_MAX_HEADER_BYTES"), "{msg}");
241	}
242
243	#[test]
244	fn env_sec_negative_integer_errors() {
245		// u32 cannot hold negative; ensure the error path fires cleanly.
246		let err = Env::from_reader(&FakeEnv::with(&[("VANE_SEC_MAX_CONN_PER_IP", "-1")]))
247			.expect_err("negative rejected");
248		assert!(err.to_string().contains("VANE_SEC_MAX_CONN_PER_IP"));
249	}
250
251	#[test]
252	fn env_mgmt_http_bind_empty_string_yields_none() {
253		let env = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_BIND", "")])).expect("ok");
254		assert!(env.mgmt_http_bind.is_none());
255	}
256
257	#[test]
258	fn env_mgmt_http_bind_valid_socketaddr_parses() {
259		let env =
260			Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_BIND", "127.0.0.1:9000")])).expect("ok");
261		let addr = env.mgmt_http_bind.expect("set");
262		assert_eq!(addr.port(), 9000);
263	}
264
265	#[test]
266	fn env_mgmt_http_bind_invalid_errors() {
267		let err = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_BIND", "not-an-addr")]))
268			.expect_err("bad addr");
269		let msg = err.to_string();
270		assert!(msg.contains("VANE_MGMT_HTTP_BIND"), "{msg}");
271		assert!(msg.contains("\"not-an-addr\""), "error quotes offending value: {msg}");
272	}
273
274	#[test]
275	fn env_mgmt_http_token_empty_string_yields_none() {
276		let env = Env::from_reader(&FakeEnv::with(&[("VANE_MGMT_HTTP_TOKEN", "")])).expect("ok");
277		assert!(env.mgmt_http_token.is_none());
278	}
279
280	#[test]
281	fn env_log_level_passes_through_verbatim() {
282		for level in ["debug", "warn", "trace", "vane=info,hyper=warn"] {
283			let env = Env::from_reader(&FakeEnv::with(&[("VANE_LOG_LEVEL", level)])).expect("ok");
284			assert_eq!(env.log_level, level);
285		}
286	}
287
288	#[test]
289	fn env_data_and_config_dirs_passed_through_as_pathbuf() {
290		let env = Env::from_reader(&FakeEnv::with(&[
291			("VANE_DATA_DIR", "/srv/vane/data"),
292			("VANE_CONFIG_DIR", "/srv/vane/etc"),
293		]))
294		.expect("ok");
295		assert_eq!(env.data_dir, PathBuf::from("/srv/vane/data"));
296		assert_eq!(env.config_dir, PathBuf::from("/srv/vane/etc"));
297	}
298}