anodizer_core/env_source.rs
1//! Process-env-or-injected-map abstraction for reads in production code.
2//!
3//! Production code reads environment variables through
4//! [`Context::env_var`](crate::context::Context::env_var), which routes
5//! through an injected [`EnvSource`] trait object. Production code uses
6//! [`ProcessEnvSource`] (calls `std::env::var`); tests inject a
7//! [`MapEnvSource`] via
8//! [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env)
9//! to drive deterministic branches without mutating the process env.
10//!
11//! ```no_run
12//! use anodizer_core::{EnvSource, MapEnvSource, ProcessEnvSource};
13//!
14//! let prod = ProcessEnvSource;
15//! let _ = prod.var("PATH");
16//!
17//! let test_src = MapEnvSource::new()
18//! .with("GITHUB_TOKEN", "ghp_synthetic")
19//! .with("CI", "true");
20//! assert_eq!(test_src.var("GITHUB_TOKEN"), Some("ghp_synthetic".to_string()));
21//! assert_eq!(test_src.var("MISSING"), None);
22//! ```
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27/// Read-only lookup for an environment variable name.
28///
29/// Production code wires up [`ProcessEnvSource`] (which calls
30/// `std::env::var`). Tests wire up [`MapEnvSource`] to drive
31/// deterministic branches without mutating the process env.
32pub trait EnvSource: Send + Sync {
33 /// Look up `name` and return its value, or `None` if unset.
34 fn var(&self, name: &str) -> Option<String>;
35
36 /// Snapshot every `(name, value)` pair this source can enumerate.
37 ///
38 /// Callers that need to scan the whole env (e.g. the determinism
39 /// harness's Windows inherit-everything pass, which drops a
40 /// credential deny-list out of the host env) use this instead of
41 /// `std::env::vars()` so a test can inject a closed map of fixture
42 /// entries. The default implementation returns an empty `Vec` so
43 /// any source that only supports point lookups (a stub, a small
44 /// fixture) stays usable for `var(...)`-only call sites without
45 /// extra boilerplate.
46 fn vars(&self) -> Vec<(String, String)> {
47 Vec::new()
48 }
49}
50
51/// Production implementation that reads `std::env::var`.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct ProcessEnvSource;
54
55impl EnvSource for ProcessEnvSource {
56 fn var(&self, name: &str) -> Option<String> {
57 std::env::var(name).ok()
58 }
59
60 fn vars(&self) -> Vec<(String, String)> {
61 std::env::vars().collect()
62 }
63}
64
65/// Map-backed implementation for tests. Built from any
66/// `IntoIterator<Item=(K,V)>` (including `HashMap<K, V>` via the
67/// [`From`] impl below) or fluently via [`MapEnvSource::with`].
68#[derive(Debug, Clone, Default)]
69pub struct MapEnvSource {
70 inner: HashMap<String, String>,
71}
72
73impl MapEnvSource {
74 /// Create an empty source. Use [`MapEnvSource::with`] to seed entries
75 /// fluently, or [`MapEnvSource::set`] for the mutable form.
76 pub fn new() -> Self {
77 Self::default()
78 }
79
80 /// Insert `(k, v)` and return `self` so calls can chain.
81 pub fn with<K: Into<String>, V: Into<String>>(mut self, k: K, v: V) -> Self {
82 self.inner.insert(k.into(), v.into());
83 self
84 }
85
86 /// Insert `(k, v)` in place. Returns `&mut self` for chained mutation.
87 pub fn set<K: Into<String>, V: Into<String>>(&mut self, k: K, v: V) -> &mut Self {
88 self.inner.insert(k.into(), v.into());
89 self
90 }
91}
92
93impl<K, V> From<HashMap<K, V>> for MapEnvSource
94where
95 K: Into<String>,
96 V: Into<String>,
97{
98 fn from(map: HashMap<K, V>) -> Self {
99 Self {
100 inner: map.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
101 }
102 }
103}
104
105impl EnvSource for MapEnvSource {
106 fn var(&self, name: &str) -> Option<String> {
107 self.inner.get(name).cloned()
108 }
109
110 fn vars(&self) -> Vec<(String, String)> {
111 self.inner
112 .iter()
113 .map(|(k, v)| (k.clone(), v.clone()))
114 .collect()
115 }
116}
117
118/// An [`EnvSource`] that overlays a small map of override entries on top of a
119/// base source.
120///
121/// A lookup returns the override value when the name is present **and
122/// non-empty**; otherwise it delegates to the base source. This lets a caller
123/// inject one or two synthetic variables (e.g. a short-lived credential minted
124/// at runtime) so that env-driven code paths — scope-availability probes,
125/// token resolvers — observe the injected value without mutating the process
126/// environment or discarding the base source's other variables.
127///
128/// An empty override string is treated as "not set" and falls through to the
129/// base, matching how the rest of the codebase treats an empty credential
130/// (`is_some_and(|t| !t.is_empty())`): overlaying `("X", "")` cannot mask a
131/// real base value.
132///
133/// ```
134/// use std::sync::Arc;
135/// use anodizer_core::{EnvSource, LayeredEnvSource, MapEnvSource};
136///
137/// let base: Arc<dyn EnvSource> = Arc::new(MapEnvSource::new().with("A", "base-a"));
138/// let layered = LayeredEnvSource::new(base, [("A", "override-a"), ("B", "override-b")]);
139/// assert_eq!(layered.var("A"), Some("override-a".to_string())); // override wins
140/// assert_eq!(layered.var("B"), Some("override-b".to_string())); // override-only key
141/// assert_eq!(layered.var("MISSING"), None); // neither has it
142/// ```
143#[derive(Clone)]
144pub struct LayeredEnvSource {
145 base: Arc<dyn EnvSource>,
146 overrides: HashMap<String, String>,
147}
148
149impl LayeredEnvSource {
150 /// Wrap `base` with the given `(name, value)` overrides. Empty override
151 /// values are retained but treated as absent by [`LayeredEnvSource::var`]
152 /// (they fall through to the base).
153 pub fn new<I, K, V>(base: Arc<dyn EnvSource>, overrides: I) -> Self
154 where
155 I: IntoIterator<Item = (K, V)>,
156 K: Into<String>,
157 V: Into<String>,
158 {
159 Self {
160 base,
161 overrides: overrides
162 .into_iter()
163 .map(|(k, v)| (k.into(), v.into()))
164 .collect(),
165 }
166 }
167}
168
169impl EnvSource for LayeredEnvSource {
170 fn var(&self, name: &str) -> Option<String> {
171 match self.overrides.get(name) {
172 Some(v) if !v.is_empty() => Some(v.clone()),
173 _ => self.base.var(name),
174 }
175 }
176
177 fn vars(&self) -> Vec<(String, String)> {
178 // Start from the base snapshot, then apply non-empty overrides so a
179 // whole-env scan (e.g. the determinism inherit-everything pass) sees
180 // the overlaid value in place of the base one.
181 let mut merged: HashMap<String, String> = self.base.vars().into_iter().collect();
182 for (k, v) in &self.overrides {
183 if !v.is_empty() {
184 merged.insert(k.clone(), v.clone());
185 }
186 }
187 merged.into_iter().collect()
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::test_helpers::env::env_mutex;
195
196 /// Picked deliberately weird so a real CI / dev shell will not have it
197 /// set. Used by the "unset variable" tests below.
198 const UNSET_VAR: &str = "ANODIZER_T3_FIXTURE_UNSET_VAR";
199
200 #[test]
201 fn process_env_source_reads_actual_env() {
202 let _g = env_mutex().lock().unwrap_or_else(|e| e.into_inner());
203 let key = "ANODIZER_T3_PROCESS_ENV_FIXTURE";
204 // env-ok: this is the contract test for ProcessEnvSource — it must
205 // observe the *real* process env, so there is no injection seam to
206 // route through; env_mutex serialises the unique-key mutation.
207 // SAFETY: serialised by env_mutex; cleaned up before guard drop.
208 // env-ok: ProcessEnvSource contract test; env_mutex-guarded, unique key
209 unsafe { std::env::set_var(key, "from-process-env") };
210 let got = ProcessEnvSource.var(key);
211 // SAFETY: serialised by env_mutex.
212 // env-ok: ProcessEnvSource contract test; env_mutex-guarded, unique key
213 unsafe { std::env::remove_var(key) };
214 assert_eq!(got, Some("from-process-env".to_string()));
215 }
216
217 #[test]
218 fn process_env_source_returns_none_for_unset_var() {
219 assert_eq!(ProcessEnvSource.var(UNSET_VAR), None);
220 }
221
222 #[test]
223 fn map_env_source_returns_inserted_value() {
224 let src = MapEnvSource::new().with("K", "V");
225 assert_eq!(src.var("K"), Some("V".to_string()));
226 }
227
228 #[test]
229 fn map_env_source_returns_none_for_missing_key() {
230 let src = MapEnvSource::new().with("K", "V");
231 assert_eq!(src.var("OTHER"), None);
232 }
233
234 #[test]
235 fn layered_env_source_override_wins_over_base() {
236 let base: Arc<dyn EnvSource> = Arc::new(MapEnvSource::new().with("K", "base"));
237 let layered = LayeredEnvSource::new(base, [("K", "overridden")]);
238 assert_eq!(layered.var("K"), Some("overridden".to_string()));
239 }
240
241 #[test]
242 fn layered_env_source_empty_override_falls_through_to_base() {
243 let base: Arc<dyn EnvSource> = Arc::new(MapEnvSource::new().with("K", "base"));
244 let layered = LayeredEnvSource::new(base, [("K", "")]);
245 // An empty override must not mask the real base value.
246 assert_eq!(layered.var("K"), Some("base".to_string()));
247 }
248
249 #[test]
250 fn layered_env_source_unset_falls_through_to_base() {
251 let base: Arc<dyn EnvSource> = Arc::new(MapEnvSource::new().with("BASE_ONLY", "b"));
252 let layered = LayeredEnvSource::new(base, [("OTHER", "o")]);
253 // A name only the base knows resolves through the base.
254 assert_eq!(layered.var("BASE_ONLY"), Some("b".to_string()));
255 // A name neither knows is None.
256 assert_eq!(layered.var("NEITHER"), None);
257 // The override-only name still resolves.
258 assert_eq!(layered.var("OTHER"), Some("o".to_string()));
259 }
260
261 #[test]
262 fn layered_env_source_vars_merges_base_and_overrides() {
263 let base: Arc<dyn EnvSource> =
264 Arc::new(MapEnvSource::new().with("A", "base-a").with("B", "b"));
265 let layered = LayeredEnvSource::new(base, [("A", "over-a"), ("C", "c")]);
266 let map: HashMap<String, String> = layered.vars().into_iter().collect();
267 assert_eq!(map.get("A").map(String::as_str), Some("over-a"));
268 assert_eq!(map.get("B").map(String::as_str), Some("b"));
269 assert_eq!(map.get("C").map(String::as_str), Some("c"));
270 }
271
272 #[test]
273 fn map_env_source_from_hashmap_preserves_entries() {
274 let mut m: HashMap<&str, &str> = HashMap::new();
275 m.insert("A", "1");
276 m.insert("B", "2");
277 let src: MapEnvSource = m.into();
278 assert_eq!(src.var("A"), Some("1".to_string()));
279 assert_eq!(src.var("B"), Some("2".to_string()));
280 assert_eq!(src.var("C"), None);
281 }
282}