Skip to main content

tfparser_core/eval/
context.rs

1//! Evaluator context: workspace root, variable + cascade bindings, function
2//! registry, env-var mode, and resource limits.
3//!
4//! Per [13-evaluator.md § 2], the [`EvalContext`] is the **read-only** input
5//! to every call into [`crate::eval::Evaluator::evaluate`]. The same context
6//! object is shared across components inside a `rayon::par_iter` per
7//! [99-key-decisions.md] D14 — `EvalContext: Send + Sync` (asserted in
8//! [`crate::eval::component`]).
9//!
10//! [13-evaluator.md § 2]: ../../../specs/13-evaluator.md
11//! [99-key-decisions.md]: ../../../specs/99-key-decisions.md
12
13use std::{collections::BTreeSet, path::Path, sync::Arc};
14
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    error::ValidationError,
19    eval::registry::FuncRegistry,
20    ir::{Map, Value},
21};
22
23/// How [`get_env`](super::registry::HclFunc) calls treat the process
24/// environment.
25///
26/// Default is [`EnvVarMode::Strict`] with an empty allowlist — `get_env` is
27/// off until the operator opts in. See [70-security.md § 3.3].
28///
29/// [70-security.md § 3.3]: ../../../specs/70-security.md
30#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "kebab-case", tag = "mode")]
32#[non_exhaustive]
33pub enum EnvVarMode {
34    /// Pass the full process environment to the evaluator. The CLI prints a
35    /// startup warning before this mode goes live.
36    Passthrough,
37    /// Only the names in `allowed` are visible. Any other `get_env(...)`
38    /// returns the supplied default or `""`.
39    Strict {
40        /// Allowed env var names. The set is a `BTreeSet` for deterministic
41        /// ordering (and dedup) per [13-evaluator.md § 2].
42        ///
43        /// [13-evaluator.md § 2]: ../../../specs/13-evaluator.md
44        allowed: BTreeSet<Arc<str>>,
45    },
46    /// `get_env` always returns the supplied default (or `""`). Useful for
47    /// hermetic tests.
48    Mock,
49}
50
51impl Default for EnvVarMode {
52    fn default() -> Self {
53        Self::Strict {
54            allowed: BTreeSet::new(),
55        }
56    }
57}
58
59impl EnvVarMode {
60    /// Whether the given env-var name may be read.
61    #[must_use]
62    pub fn allows(&self, name: &str) -> bool {
63        match self {
64            Self::Passthrough => true,
65            Self::Strict { allowed } => allowed.iter().any(|s| s.as_ref() == name),
66            Self::Mock => false,
67        }
68    }
69
70    /// Whether to consult the process environment or always return the
71    /// caller's default. `Mock` short-circuits even when the name is empty.
72    #[must_use]
73    pub const fn is_mock(&self) -> bool {
74        matches!(self, Self::Mock)
75    }
76}
77
78/// Per-call resource limits enforced by the evaluator and its registered
79/// functions.
80///
81/// Per [70-security.md § 3.2] every cap is configurable; the defaults match
82/// the spec. Breaching any returns `Err(EvalError::Limit { kind, ... })` —
83/// never a panic.
84///
85/// [70-security.md § 3.2]: ../../../specs/70-security.md
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[non_exhaustive]
88#[serde(rename_all = "camelCase")]
89pub struct EvalLimits {
90    /// Maximum positional+variadic arg count per function call. Default: 64.
91    pub max_func_args: u32,
92    /// Maximum rendered string size returned by a function (bytes).
93    /// Default: 1 MiB.
94    pub max_str_size: u32,
95    /// Maximum list/array size returned by a function. Default: 100 000.
96    pub max_list_len: u32,
97    /// Maximum reduction iterations across a single `evaluate` call.
98    /// Default: 1 000 000. Anti-DoS bound for nested `for` / recursive
99    /// templates.
100    pub max_iterations: u32,
101    /// Maximum read size for sandboxed file functions (bytes). Default:
102    /// 4 MiB (matches the loader's per-file cap).
103    pub max_file_bytes: u32,
104}
105
106impl Default for EvalLimits {
107    fn default() -> Self {
108        Self {
109            max_func_args: 64,
110            max_str_size: 1 << 20,
111            max_list_len: 100_000,
112            max_iterations: 1_000_000,
113            max_file_bytes: 4 << 20,
114        }
115    }
116}
117
118/// Read-only inputs to a single [`crate::eval::Evaluator::evaluate`] call.
119///
120/// `repo_vars` and `cascade_locals` are insertion-ordered [`Map`]s (see
121/// [10-data-model.md § 2.3]) — order preservation matters for the canonical
122/// JSON in error diagnostics. `funcs` is an `Arc<FuncRegistry>` so the same
123/// table is shared across all components in a workspace without cloning.
124///
125/// [10-data-model.md § 2.3]: ../../../specs/10-data-model.md
126#[derive(Clone, Debug)]
127#[non_exhaustive]
128pub struct EvalContext {
129    /// Absolute, canonicalised workspace root. All sandboxed file functions
130    /// validate paths against this root.
131    pub workspace_root: Arc<Path>,
132
133    /// Optional environment name (e.g. `Some("staging")`). Bound as the
134    /// `terraform.workspace` placeholder for the evaluator pass; downstream
135    /// resource rows surface it in the `environment` column.
136    pub environment: Option<Arc<str>>,
137
138    /// How `get_env(...)` reads the process environment.
139    pub env_vars: EnvVarMode,
140
141    /// `var.*` bindings from `.tfvars` and CLI `--var k=v` flags.
142    pub repo_vars: Map,
143
144    /// `local.*` shadows injected from a Terragrunt cascade (Phase 6 will
145    /// populate this; Phase 4 accepts an empty map and a synthetic one for
146    /// tests).
147    pub cascade_locals: Map,
148
149    /// Registered functions (stdlib + Terraform-only + sandboxed file +
150    /// Terragrunt helpers).
151    pub funcs: Arc<FuncRegistry>,
152
153    /// Per-call resource limits.
154    pub limits: EvalLimits,
155}
156
157impl EvalContext {
158    /// Construct a fully-specified [`EvalContext`]. Public because
159    /// downstream crates (the CLI, the future server) build contexts
160    /// from their own config sources; the struct is `#[non_exhaustive]`
161    /// so the constructor is the stable surface even when fields evolve.
162    #[must_use]
163    pub fn new(
164        workspace_root: Arc<Path>,
165        environment: Option<Arc<str>>,
166        env_vars: EnvVarMode,
167        repo_vars: Map,
168        cascade_locals: Map,
169        funcs: Arc<FuncRegistry>,
170        limits: EvalLimits,
171    ) -> Self {
172        Self {
173            workspace_root,
174            environment,
175            env_vars,
176            repo_vars,
177            cascade_locals,
178            funcs,
179            limits,
180        }
181    }
182
183    /// Construct a minimal context pinned to `workspace_root` with default
184    /// limits, empty `repo_vars` / `cascade_locals`, an empty registry,
185    /// and strict env-var mode (empty allowlist). Convenience for tests
186    /// and the default CLI invocation.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`ValidationError::Empty`] when `workspace_root` is an empty
191    /// path — the evaluator requires a non-empty root for the sandboxed
192    /// path helpers to reject escapes meaningfully.
193    pub fn minimal(workspace_root: Arc<Path>) -> Result<Self, ValidationError> {
194        if workspace_root.as_os_str().is_empty() {
195            return Err(ValidationError::Empty {
196                field: "EvalContext.workspace_root",
197            });
198        }
199        Ok(Self {
200            workspace_root,
201            environment: None,
202            env_vars: EnvVarMode::default(),
203            repo_vars: Map::new(),
204            cascade_locals: Map::new(),
205            funcs: Arc::new(FuncRegistry::default()),
206            limits: EvalLimits::default(),
207        })
208    }
209
210    /// Read a binding for `name` from `repo_vars`.
211    #[must_use]
212    pub fn lookup_repo_var(&self, name: &str) -> Option<&Value> {
213        self.repo_vars
214            .iter()
215            .find_map(|(k, v)| if k.as_ref() == name { Some(v) } else { None })
216    }
217
218    /// Read a binding for `name` from `cascade_locals`.
219    #[must_use]
220    pub fn lookup_cascade_local(&self, name: &str) -> Option<&Value> {
221        self.cascade_locals.iter().find_map(
222            |(k, v)| {
223                if k.as_ref() == name { Some(v) } else { None }
224            },
225        )
226    }
227}
228
229#[cfg(test)]
230#[allow(
231    clippy::unwrap_used,
232    clippy::expect_used,
233    clippy::panic,
234    clippy::indexing_slicing
235)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_default_env_var_mode_is_strict_empty() {
241        let m = EnvVarMode::default();
242        assert!(!m.allows("HOME"));
243        assert!(!m.is_mock());
244    }
245
246    #[test]
247    fn test_strict_mode_allows_listed_names_only() {
248        let mut allowed = BTreeSet::new();
249        allowed.insert(Arc::<str>::from("TF_VAR_environment"));
250        let m = EnvVarMode::Strict { allowed };
251        assert!(m.allows("TF_VAR_environment"));
252        assert!(!m.allows("HOME"));
253    }
254
255    #[test]
256    fn test_passthrough_allows_everything() {
257        let m = EnvVarMode::Passthrough;
258        assert!(m.allows("ANY"));
259    }
260
261    #[test]
262    fn test_mock_rejects_all_and_is_mock() {
263        let m = EnvVarMode::Mock;
264        assert!(!m.allows("HOME"));
265        assert!(m.is_mock());
266    }
267
268    #[test]
269    fn test_eval_limits_defaults_match_spec() {
270        let l = EvalLimits::default();
271        assert_eq!(l.max_func_args, 64);
272        assert_eq!(l.max_str_size, 1 << 20);
273        assert_eq!(l.max_list_len, 100_000);
274        assert_eq!(l.max_iterations, 1_000_000);
275    }
276
277    #[test]
278    fn test_minimal_context_rejects_empty_root() {
279        let err = EvalContext::minimal(Arc::from(Path::new(""))).unwrap_err();
280        assert!(matches!(err, ValidationError::Empty { .. }));
281    }
282
283    #[test]
284    fn test_minimal_context_round_trips_vars() {
285        let mut ctx = EvalContext::minimal(Arc::from(Path::new("/tmp/repo"))).unwrap();
286        ctx.repo_vars
287            .push((Arc::from("region"), Value::Str(Arc::from("us-east-2"))));
288        assert_eq!(
289            ctx.lookup_repo_var("region"),
290            Some(&Value::Str(Arc::from("us-east-2")))
291        );
292        assert_eq!(ctx.lookup_repo_var("missing"), None);
293    }
294
295    #[test]
296    fn test_env_mode_serde_round_trip() {
297        let m = EnvVarMode::Strict {
298            allowed: [Arc::<str>::from("AWS_REGION")].into_iter().collect(),
299        };
300        let json = serde_json::to_string(&m).unwrap();
301        let back: EnvVarMode = serde_json::from_str(&json).unwrap();
302        assert_eq!(m, back);
303    }
304}