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}