//! MiniJinja-based template environment with HA-compatible globals. //! //! ADR-129 §2.1 — P1 ships four HA globals: `states()`, `state_attr()`, //! `is_state()`, `now()`. The `utcnow()`, `as_timestamp()`, `distance()`, //! and `iif()` globals plus custom filters land in P2. use std::sync::Arc; use chrono::Utc; use minijinja::{Environment, Value}; use homecore::{EntityId, StateMachine}; use crate::error::AutomationError; /// Instruction budget for a single template render (HC-SEC-01). /// /// Templates come from user automation config; without a bound a single /// `template:` condition like /// `{% for i in range(10000) %}{% for j in range(10000) %}x{% endfor %}{% endfor %}` /// renders a multi-gigabyte string and pins a CPU for tens of seconds — /// a memory/CPU denial-of-service (the bfld-class "unbounded expansion"). /// MiniJinja's `fuel` feature charges ~1 unit per VM instruction; a /// nested loop burns one unit per iteration, so the budget caps total /// work regardless of how the loops are nested. 1,000,000 instructions is /// far more than any legitimate HA template needs (a typical condition is /// a few dozen) while killing the attack in well under a second. const TEMPLATE_FUEL: u64 = 1_000_000; /// Hard cap on the source length of a template (HC-SEC-01, defense in /// depth). A legitimate HA `value_template` is a one-liner; anything past /// 64 KiB is rejected before compilation so a pathological source string /// can neither be compiled nor emitted verbatim. const MAX_TEMPLATE_SOURCE_BYTES: usize = 64 * 1024; /// MiniJinja environment pre-loaded with HA-compatible globals. /// /// Constructed once per `AutomationEngine` and shared via `Arc`. The /// globals close over an `Arc` so every template render /// sees the live current state. pub struct TemplateEnvironment { env: Environment<'static>, } impl TemplateEnvironment { /// Build a new environment backed by the given state machine. pub fn new(states: Arc) -> Self { let mut env = Environment::new(); // Bound per-render work so a hostile `template:` condition cannot // DoS the engine via nested loops / huge repeats (HC-SEC-01). env.set_fuel(Some(TEMPLATE_FUEL)); // --- states(entity_id) --- // Returns the current state string of an entity, or "unavailable". let states_sm = Arc::clone(&states); env.add_global( "states", Value::from_function(move |entity_id: String| -> String { EntityId::parse(&entity_id) .ok() .and_then(|eid| states_sm.get(&eid)) .map(|s| s.state.clone()) .unwrap_or_else(|| "unavailable".into()) }), ); // --- state_attr(entity_id, attribute) --- // Returns an attribute value as a JSON string, or empty string. let attr_sm = Arc::clone(&states); env.add_global( "state_attr", Value::from_function(move |entity_id: String, attr: String| -> String { EntityId::parse(&entity_id) .ok() .and_then(|eid| attr_sm.get(&eid)) .and_then(|s| s.attributes.get(&attr).cloned()) .map(|v| match v { serde_json::Value::String(s) => s, other => other.to_string(), }) .unwrap_or_default() }), ); // --- is_state(entity_id, state) --- // Returns true if the entity's current state matches the given value. let is_state_sm = Arc::clone(&states); env.add_global( "is_state", Value::from_function(move |entity_id: String, expected: String| -> bool { EntityId::parse(&entity_id) .ok() .and_then(|eid| is_state_sm.get(&eid)) .map(|s| s.state == expected) .unwrap_or(false) }), ); // --- now() --- // Returns the current UTC datetime as an ISO 8601 string. // HA returns a Python datetime; MiniJinja returns a string which // templates can further format with the `strftime` filter. env.add_global( "now", Value::from_function(|| -> String { Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string() }), ); Self { env } } /// Render a template string and return the string output. /// /// Renders are bounded by an instruction budget ([`TEMPLATE_FUEL`]) and /// a source-length cap ([`MAX_TEMPLATE_SOURCE_BYTES`]); a malicious /// template that exhausts the budget returns a [`AutomationError::TemplateRender`] /// error rather than running unbounded (HC-SEC-01). pub fn render(&self, template_str: &str) -> Result { // Reject pathologically large sources before compilation (defense // in depth — fuel already bounds runtime work). if template_str.len() > MAX_TEMPLATE_SOURCE_BYTES { return Err(AutomationError::TemplateRender(format!( "template source too large: {} bytes (max {})", template_str.len(), MAX_TEMPLATE_SOURCE_BYTES ))); } // Wrap bare expressions like `{{ states('light.kitchen') }}` // in a minimal template wrapper. let tmpl = self .env .template_from_str(template_str) .map_err(|e| AutomationError::TemplateRender(e.to_string()))?; tmpl.render(()) .map_err(|e| AutomationError::TemplateRender(e.to_string())) } /// Render a template and interpret the output as a boolean. /// "true", "1", "yes", "on" → true. Everything else → false. pub fn render_bool(&self, template_str: &str) -> Result { let raw = self.render(template_str)?; let v = raw.trim().to_ascii_lowercase(); Ok(matches!(v.as_str(), "true" | "1" | "yes" | "on")) } } #[cfg(test)] mod tests { use super::*; use homecore::{Context, EntityId, StateMachine}; use std::sync::Arc; fn sm_with(entity_id: &str, state: &str, attrs: serde_json::Value) -> Arc { let sm = Arc::new(StateMachine::new()); sm.set(EntityId::parse(entity_id).unwrap(), state, attrs, Context::new()); sm } #[test] fn states_global_returns_current_state() { let sm = sm_with("light.kitchen", "on", serde_json::json!({})); let env = TemplateEnvironment::new(sm); let out = env.render("{{ states('light.kitchen') }}").unwrap(); assert_eq!(out.trim(), "on"); } #[test] fn states_global_unknown_entity_returns_unavailable() { let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); let out = env.render("{{ states('sensor.unknown') }}").unwrap(); assert_eq!(out.trim(), "unavailable"); } #[test] fn state_attr_returns_attribute_value() { let sm = sm_with( "light.kitchen", "on", serde_json::json!({"brightness": 200}), ); let env = TemplateEnvironment::new(sm); let out = env.render("{{ state_attr('light.kitchen', 'brightness') }}").unwrap(); assert_eq!(out.trim(), "200"); } #[test] fn is_state_global_true_when_matches() { let sm = sm_with("switch.fan", "on", serde_json::json!({})); let env = TemplateEnvironment::new(sm); let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap(); assert_eq!(out.trim(), "true"); } #[test] fn is_state_global_false_when_no_match() { let sm = sm_with("switch.fan", "off", serde_json::json!({})); let env = TemplateEnvironment::new(sm); let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap(); assert_eq!(out.trim(), "false"); } #[test] fn now_global_returns_timestamp_string() { let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); let out = env.render("{{ now() }}").unwrap(); // Should be an ISO 8601 datetime string containing 'T' assert!(out.contains('T'), "now() returned: {out}"); } #[test] fn render_bool_true_values() { let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); for tmpl in &["true", "1", "yes", "on"] { let result = env.render_bool(tmpl).unwrap(); assert!(result, "expected true for: {tmpl}"); } } #[test] fn render_bool_false_for_other() { let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); assert!(!env.render_bool("false").unwrap()); assert!(!env.render_bool("0").unwrap()); assert!(!env.render_bool("off").unwrap()); } // ── HC-SEC-01: template DoS is bounded by fuel ───────────────────── // // A `template:` condition is user config. Before the fuel bound a // nested-loop template rendered a multi-GB string over ~11 s (proven // empirically). With fuel enabled it must fail FAST with an error // instead of expanding unboundedly. On the pre-fix code (no `fuel` // feature / `set_fuel`) this render succeeds and burns CPU+RAM, so // this test fails on old (it would `Ok` and exceed the time bound). #[test] fn nested_loop_template_is_bounded_not_unbounded_dos() { use std::time::Instant; let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); // 5000 * 5000 = 25M iterations on the old engine (~100 MB, ~11 s). let malicious = "{% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %}"; let start = Instant::now(); let result = env.render(malicious); let elapsed = start.elapsed(); assert!( result.is_err(), "malicious nested-loop template must be rejected (ran out of fuel), got Ok" ); assert!( elapsed.as_secs() < 3, "bounded render must fail fast; took {elapsed:?} (unbounded DoS on old engine)" ); } // ── HC-SEC-01: a single huge repeat is also bounded ──────────────── #[test] fn single_huge_repeat_template_is_bounded() { let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); // range() caps at 10k per call, but multiplied bodies still need a // bound; drive enough instructions to exhaust fuel via deep nesting. let malicious = "{% for a in range(9999) %}{% for b in range(9999) %}\ {% for c in range(9999) %}z{% endfor %}{% endfor %}{% endfor %}"; let result = env.render(malicious); assert!(result.is_err(), "deeply nested loops must exhaust fuel and error"); } // ── HC-SEC-01: oversized template source is rejected pre-compile ─── #[test] fn oversized_template_source_is_rejected() { let sm = Arc::new(StateMachine::new()); let env = TemplateEnvironment::new(sm); // 128 KiB of literal text — exceeds MAX_TEMPLATE_SOURCE_BYTES. let big = "x".repeat(128 * 1024); let result = env.render(&big); assert!(result.is_err(), "oversized template source must be rejected"); } // ── A legitimate small template still renders fine within budget ─── #[test] fn legitimate_template_still_renders_within_fuel() { let sm = sm_with("light.kitchen", "on", serde_json::json!({})); let env = TemplateEnvironment::new(sm); // A normal HA condition with a modest loop — well under budget. let ok = "{% for i in range(50) %}{{ states('light.kitchen') }}{% endfor %}"; let out = env.render(ok).expect("legitimate template must render"); assert!(out.contains("on")); } }