Skip to main content

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