Skip to main content

cel_engine/
engine.rs

1use log::info;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use diagnostics::{Diagnostic, Entity, PhaseMetric, phase_metric};
6use guard_translator::{ensure_translatable, pack_name_from_path, parse_guard};
7use rules::{RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, build_rule_metadata_map, is_valid_custom_rule_id};
8use schema_validator::{OverlayCatalog, SchemaValidator};
9use template_model::{SemanticModel, UNKNOWN_SPAN};
10use validation_engine::{
11    EngineConfig, ValidateConfig, ValidationEngine, ValidationError, build_rule_list, semantic_model_to_input_json,
12};
13
14use crate::rule_evaluator::GeneratedRuleRegistry;
15use crate::rules::{CachedData, EvalContext, NativeRuleRegistry};
16
17use serde::Deserialize;
18
19use cel_interpreter::{Context, Program, Value as CelValue};
20
21#[derive(Deserialize)]
22struct CustomRuleFile {
23    rules: Vec<CustomRuleDef>,
24}
25
26#[derive(Deserialize)]
27struct CustomRuleDef {
28    rule_id: String,
29    severity: Severity,
30    #[serde(default)]
31    category: Option<String>,
32    resource_type: Option<String>,
33    expression: String,
34    message: String,
35    #[serde(default)]
36    prop_path: Option<String>,
37    #[serde(default)]
38    suggested_fix: Option<String>,
39}
40
41pub struct CelEngine {
42    native_rules: NativeRuleRegistry,
43    generated_rules: GeneratedRuleRegistry,
44    custom_rules: Vec<CustomRule>,
45    /// Built-in rule metadata from the rules registry only.
46    registry_metadata: HashMap<String, RuleMetadataEntry>,
47    /// Metadata for custom user rules and translated guard rules.
48    external_rule_metadata: HashMap<String, RuleMetadataEntry>,
49    cached_data: CachedData,
50    init_metric: PhaseMetric,
51}
52
53#[derive(Debug)]
54struct CustomRule {
55    rule_id: String,
56    severity: Severity,
57    category: Option<String>,
58    resource_type: Option<String>,
59    program: Program,
60    message: String,
61    prop_path: Option<String>,
62    suggested_fix: Option<String>,
63    source: RuleOrigin,
64}
65
66impl CelEngine {
67    pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
68        let catalog =
69            config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
70        Self::new_from_catalog(config, &catalog)
71    }
72
73    /// Constructs the engine reusing metadata from an already-built
74    /// [`SchemaValidator`]. The validator's overlay catalog is treated as
75    /// authoritative - the engine does not re-resolve overlay schemas.
76    ///
77    /// This entry point is intended for language bindings and the CLI, which
78    /// construct a `SchemaValidator` once and share it with the engine.
79    #[doc(hidden)]
80    pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
81        Self::new_from_catalog(config, validator.overlay_catalog())
82    }
83
84    /// Internal constructor that accepts a pre-built overlay catalog.
85    fn new_from_catalog(config: EngineConfig, overlay_catalog: &OverlayCatalog) -> anyhow::Result<Self> {
86        let start = web_time::Instant::now();
87
88        let native_rules = NativeRuleRegistry::new();
89        let generated_rules = GeneratedRuleRegistry::new()?;
90
91        let registry_metadata = build_rule_metadata_map();
92        let mut external_rule_metadata: HashMap<String, RuleMetadataEntry> = HashMap::new();
93
94        let mut translated_guard_sources = Vec::new();
95        for entry in &config.guard_rules {
96            let guard_file = parse_guard(&entry.content, &entry.name)
97                .map_err(|e| anyhow::anyhow!("Failed to parse guard file '{}': {}", entry.name, e))?;
98            ensure_translatable(&guard_file)
99                .map_err(|e| anyhow::anyhow!("Unsupported guard rule in '{}': {}", entry.name, e))?;
100            let pack = pack_name_from_path(&entry.name);
101            let translated = crate::guard_to_cel::translate_to_cel(&guard_file, &pack, &[]);
102            let json = crate::guard_to_cel::to_custom_rule_json(&translated)
103                .map_err(|e| anyhow::anyhow!("Failed to translate guard file '{}' to CEL: {}", entry.name, e))?;
104            translated_guard_sources.push((entry.name.clone(), json));
105        }
106
107        let mut custom_rules = Vec::new();
108        for entry in &config.custom_rules {
109            match load_custom_rules(&entry.content, RuleOrigin::Custom) {
110                Ok(rules) => {
111                    info!("Loaded {} custom rules from {}", rules.len(), entry.name);
112                    for r in &rules {
113                        external_rule_metadata.entry(r.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
114                            category: r.category.clone(),
115                            description: r.message.clone(),
116                            severity: r.severity,
117                            origin: RuleOrigin::Custom,
118                        });
119                    }
120                    custom_rules.extend(rules);
121                }
122                Err(e) => {
123                    return Err(anyhow::anyhow!("Failed to load rules from {}: {}", entry.name, e));
124                }
125            }
126        }
127        for (path, source) in &translated_guard_sources {
128            match load_custom_rules(source, RuleOrigin::Guard) {
129                Ok(rules) => {
130                    info!("Loaded {} guard rules from {}", rules.len(), path);
131                    for r in &rules {
132                        external_rule_metadata.entry(r.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
133                            category: r.category.clone(),
134                            description: r.message.clone(),
135                            severity: r.severity,
136                            origin: RuleOrigin::Guard,
137                        });
138                    }
139                    custom_rules.extend(rules);
140                }
141                Err(e) => {
142                    return Err(anyhow::anyhow!("Failed to load guard rules from {}: {}", path, e));
143                }
144            }
145        }
146
147        info!(
148            "CelEngine initialized: {} native rule fns, {} custom rules, {} registry + {} external metadata entries",
149            native_rules.rules.len(),
150            custom_rules.len(),
151            registry_metadata.len(),
152            external_rule_metadata.len()
153        );
154        let mut cached_data = CachedData::load()?;
155        // Resource types introduced by an overlay schema are legitimate targets,
156        // so rules working from the build-time type catalog must treat them as
157        // known rather than reporting them as nonexistent.
158        if !overlay_catalog.is_empty() {
159            cached_data.merge_overlay_catalog(overlay_catalog)?;
160        }
161        let init_metric = phase_metric(start);
162        Ok(CelEngine {
163            native_rules,
164            generated_rules,
165            custom_rules,
166            registry_metadata,
167            external_rule_metadata,
168            cached_data,
169            init_metric,
170        })
171    }
172}
173
174impl ValidationEngine for CelEngine {
175    fn engine_name(&self) -> &str {
176        "cel"
177    }
178
179    fn evaluate_rules(
180        &self,
181        model: &Arc<SemanticModel>,
182        config: &ValidateConfig,
183    ) -> Result<Vec<Diagnostic>, ValidationError> {
184        let input_json = semantic_model_to_input_json(model)?;
185        let excluded_cats = config.filters.excluded_categories();
186        let region = config.pseudo_parameter_overrides.region.clone();
187
188        let mut diagnostics = if config.disable_builtin_rules {
189            Vec::new()
190        } else {
191            let ctx = EvalContext { model, input: &input_json, region: &region, cached_data: &self.cached_data };
192            let mut diags = self.native_rules.evaluate(&ctx, &excluded_cats);
193            let gen_diags = self.generated_rules.evaluate(model, &input_json, &excluded_cats);
194            diags.extend(gen_diags);
195            diags
196        };
197
198        for rule in &self.custom_rules {
199            if rule.category.as_deref().is_some_and(|c| excluded_cats.contains(c)) {
200                continue;
201            }
202            if let Some(ref rtype) = rule.resource_type {
203                for rid in model.resources_of_type(rtype) {
204                    let cel_ctx = crate::functions::build_custom_context(&input_json, Some(rid), Some(model));
205                    if execute_custom_rule(rule, &cel_ctx)? {
206                        let msg = rule.message.replace("{name}", rid);
207                        emit_custom_diagnostic(&mut diagnostics, rule, model, rid, &msg);
208                    }
209                }
210            } else {
211                let cel_ctx = crate::functions::build_custom_context(&input_json, None, Some(model));
212                if execute_custom_rule(rule, &cel_ctx)? {
213                    emit_custom_diagnostic(&mut diagnostics, rule, model, "", &rule.message);
214                }
215            }
216        }
217
218        Ok(diagnostics)
219    }
220
221    fn list_rules(&self) -> Vec<RuleInfo> {
222        build_rule_list(&self.registry_metadata, &self.external_rule_metadata)
223    }
224
225    fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
226        &self.registry_metadata
227    }
228
229    fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
230        self.external_rule_metadata.clone()
231    }
232
233    fn init_metric(&self) -> &PhaseMetric {
234        &self.init_metric
235    }
236}
237
238fn execute_custom_rule(rule: &CustomRule, cel_ctx: &Context<'static>) -> Result<bool, ValidationError> {
239    match rule.program.execute(cel_ctx) {
240        Ok(CelValue::Bool(fired)) => Ok(fired),
241        // A rule expression must decide whether the rule fires, so it must produce a
242        // boolean. A non-boolean result means the expression is malformed (e.g. it
243        // names a property instead of testing one); treating it as "did not fire"
244        // would let the mistake pass silently, so surface it as an error.
245        Ok(_) => Err(ValidationError::Engine(format!(
246            "Custom rule '{}' expression must evaluate to a boolean, but produced a non-boolean value",
247            rule.rule_id
248        ))),
249        // A translated Guard clause reads properties that may be absent, so evaluation
250        // errors on the missing key. Guard's semantics treat an absent property as a
251        // check that simply does not pass, so tolerate the error as "did not fire"
252        // rather than surfacing it.
253        Err(error) if matches!(rule.source, RuleOrigin::Guard) => {
254            log::error!("Guard rule '{}' failed to evaluate (tolerated): {error}", rule.rule_id);
255            Ok(false)
256        }
257        Err(error) => {
258            Err(ValidationError::Engine(format!("Custom rule '{}' failed to evaluate: {error}", rule.rule_id)))
259        }
260    }
261}
262
263// Custom and Guard rules are not in the rule registry, so their severity,
264// category, and origin come from the parsed rule rather than the registry-driven
265// `RegisteredDiagnostic` builder used for built-in rules.
266fn emit_custom_diagnostic(
267    out: &mut Vec<Diagnostic>,
268    rule: &CustomRule,
269    model: &Arc<SemanticModel>,
270    rid: &str,
271    msg: &str,
272) {
273    let span =
274        if rid.is_empty() { UNKNOWN_SPAN } else { model.resource_span(rid, rule.prop_path.as_deref().unwrap_or("")) };
275    out.push(Diagnostic {
276        rule_id: rule.rule_id.clone(),
277        severity: rule.severity,
278        message: msg.to_string(),
279        entity: Entity::resource(rid, model.resources.get(rid).map(|r| r.resource_type.clone())),
280        property_path: rule.prop_path.clone(),
281        suggested_fix: rule.suggested_fix.clone(),
282        documentation_url: None,
283        category: rule.category.clone(),
284        location: if span == UNKNOWN_SPAN { None } else { Some(span) },
285        related_resources: None,
286        condition_scenario: None,
287        rule_description: None,
288        phase: None,
289        context: None,
290        source: rule.source,
291    });
292}
293
294fn load_custom_rules(source: &str, origin: RuleOrigin) -> anyhow::Result<Vec<CustomRule>> {
295    let file: CustomRuleFile = serde_json::from_str(source)?;
296    let mut rules = Vec::new();
297    for def in file.rules {
298        // Required text fields must be present and non-blank. `serde` guarantees the
299        // keys exist; these checks reject empty values that would otherwise yield a
300        // diagnostic with no rule ID or no message.
301        if def.rule_id.trim().is_empty() {
302            return Err(anyhow::anyhow!("Custom rule has an empty 'rule_id'"));
303        }
304        // A custom rule ID may be any run of letters, digits, and the separators
305        // `_`, `.`, `-` - it need not follow the built-in ID convention - but must
306        // exclude whitespace and other punctuation that would corrupt formatting,
307        // filtering, and de-duplication of diagnostics.
308        if !is_valid_custom_rule_id(&def.rule_id) {
309            return Err(anyhow::anyhow!(
310                "Custom rule '{}' has an invalid 'rule_id': only letters, digits, and the separators '_', '.', '-' \
311                 are allowed",
312                def.rule_id
313            ));
314        }
315        if def.message.trim().is_empty() {
316            return Err(anyhow::anyhow!("Custom rule '{}' has an empty 'message'", def.rule_id));
317        }
318        if def.expression.trim().is_empty() {
319            return Err(anyhow::anyhow!("Custom rule '{}' has an empty 'expression'", def.rule_id));
320        }
321        let program = Program::compile(&def.expression)
322            .map_err(|e| anyhow::anyhow!("Failed to compile CEL expression for rule '{}': {}", def.rule_id, e))?;
323        // A call to a function the interpreter cannot resolve errors only when the
324        // expression is actually evaluated, so a rule scoped to an absent resource
325        // type would load clean and silently never run. Reject it at load time
326        // instead, when the failure is certain regardless of the template.
327        for function in program.references().functions() {
328            if !crate::functions::is_supported_function(function) {
329                return Err(anyhow::anyhow!(
330                    "Custom rule '{}' references unknown function '{}'",
331                    def.rule_id,
332                    function
333                ));
334            }
335        }
336        rules.push(CustomRule {
337            rule_id: def.rule_id,
338            severity: def.severity,
339            category: def.category,
340            resource_type: def.resource_type,
341            program,
342            message: def.message,
343            prop_path: def.prop_path,
344            suggested_fix: def.suggested_fix,
345            source: origin,
346        });
347    }
348    Ok(rules)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn load_custom_rules_valid_json() {
357        let json = r#"{"rules": [
358            {
359                "rule_id": "CUSTOM001",
360                "severity": "ERROR",
361                "resource_type": "AWS::S3::Bucket",
362                "expression": "has(properties.BucketEncryption)",
363                "message": "Encryption required"
364            }
365        ]}"#;
366        let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
367        assert_eq!(rules.len(), 1);
368        assert_eq!(rules[0].rule_id, "CUSTOM001");
369        assert!(matches!(rules[0].severity, Severity::Error));
370        assert_eq!(rules[0].resource_type, Some("AWS::S3::Bucket".into()));
371        assert_eq!(rules[0].category, None);
372    }
373
374    #[test]
375    fn load_custom_rules_severity_mapping() {
376        let make = |sev: &str| -> String {
377            format!(
378                r#"{{"rules": [{{"rule_id": "R1", "severity": "{}", "expression": "true", "message": "m"}}]}}"#,
379                sev
380            )
381        };
382
383        let error_rules = load_custom_rules(&make(Severity::Error.as_str()), RuleOrigin::Custom).unwrap();
384        assert_eq!(error_rules[0].severity, Severity::Error);
385
386        let warn_rules = load_custom_rules(&make(Severity::Warn.as_str()), RuleOrigin::Custom).unwrap();
387        assert_eq!(warn_rules[0].severity, Severity::Warn);
388
389        let info_rules = load_custom_rules(&make(Severity::Info.as_str()), RuleOrigin::Custom).unwrap();
390        assert_eq!(info_rules[0].severity, Severity::Info);
391    }
392
393    #[test]
394    fn load_custom_rules_custom_category() {
395        let json = r#"{"rules": [{
396            "rule_id": "R1",
397            "severity": "ERROR",
398            "category": "security",
399            "expression": "true",
400            "message": "m"
401        }]}"#;
402        let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
403        assert_eq!(rules[0].category, Some("security".into()));
404    }
405
406    #[test]
407    fn load_custom_rules_optional_fields() {
408        let json = r#"{"rules": [{
409            "rule_id": "R1",
410            "severity": "ERROR",
411            "expression": "true",
412            "message": "m",
413            "prop_path": "Properties.X",
414            "suggested_fix": "Add X"
415        }]}"#;
416        let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
417        assert_eq!(rules[0].prop_path, Some("Properties.X".into()));
418        assert_eq!(rules[0].suggested_fix, Some("Add X".into()));
419    }
420
421    #[test]
422    fn load_custom_rules_global_rule_no_resource_type() {
423        let json = r#"{"rules": [{
424            "rule_id": "R1",
425            "severity": "ERROR",
426            "expression": "true",
427            "message": "m"
428        }]}"#;
429        let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
430        assert_eq!(rules[0].resource_type, None);
431    }
432
433    #[test]
434    fn load_custom_rules_invalid_json() {
435        let result = load_custom_rules("not json", RuleOrigin::Custom);
436        result.unwrap_err();
437    }
438
439    #[test]
440    fn load_custom_rules_bad_cel_expression() {
441        let json = r#"{"rules": [{
442            "rule_id": "R1",
443            "severity": "ERROR",
444            "expression": "((( invalid cel",
445            "message": "m"
446        }]}"#;
447        let result = load_custom_rules(json, RuleOrigin::Custom);
448        let err_msg = format!("{}", result.unwrap_err());
449        assert!(err_msg.contains("R1"), "Error should mention rule_id, got: {}", err_msg);
450    }
451
452    #[test]
453    fn load_custom_rules_empty_rules_array() {
454        let json = r#"{"rules": []}"#;
455        let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
456        assert!(rules.is_empty());
457    }
458
459    #[test]
460    fn load_custom_rules_empty_rule_id_is_rejected() {
461        let json = r#"{"rules": [{"rule_id": "", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
462        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
463        assert!(err.contains("empty 'rule_id'"), "an empty rule_id must be rejected, got: {err}");
464    }
465
466    #[test]
467    fn load_custom_rules_blank_rule_id_is_rejected() {
468        let json = r#"{"rules": [{"rule_id": "   ", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
469        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
470        assert!(err.contains("empty 'rule_id'"), "a whitespace-only rule_id must be rejected, got: {err}");
471    }
472
473    #[test]
474    fn load_custom_rules_arbitrary_alphanumeric_id_with_separators_is_accepted() {
475        // A custom rule ID need not follow the built-in convention: letters, digits,
476        // and the separators `_`, `.`, `-` are all permitted.
477        let json = r#"{"rules": [{"rule_id": "s3.encryption-required_1", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
478        let rules = load_custom_rules(json, RuleOrigin::Custom).expect("an alphanumeric+separator id must load");
479        assert_eq!(rules[0].rule_id, "s3.encryption-required_1");
480    }
481
482    #[test]
483    fn load_custom_rules_rule_id_with_space_or_punctuation_is_rejected() {
484        let json = r#"{"rules": [{"rule_id": "bad id", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
485        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
486        assert!(err.contains("invalid 'rule_id'"), "a rule_id with a space must be rejected, got: {err}");
487
488        let json = r#"{"rules": [{"rule_id": "bad/id", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
489        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
490        assert!(err.contains("invalid 'rule_id'"), "a rule_id with punctuation must be rejected, got: {err}");
491    }
492
493    #[test]
494    fn load_custom_rules_empty_message_is_rejected() {
495        let json = r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": ""}]}"#;
496        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
497        assert!(err.contains("R1") && err.contains("empty 'message'"), "an empty message must be rejected, got: {err}");
498    }
499
500    #[test]
501    fn load_custom_rules_empty_expression_is_rejected() {
502        let json = r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "  ", "message": "m"}]}"#;
503        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
504        assert!(
505            err.contains("R1") && err.contains("empty 'expression'"),
506            "a blank expression must be rejected, got: {err}"
507        );
508    }
509
510    #[test]
511    fn load_custom_rules_unknown_function_is_rejected_at_load() {
512        // The failure must surface at load time, not only when a matching resource
513        // happens to exist during evaluation.
514        let json = r#"{"rules": [{
515            "rule_id": "R1",
516            "severity": "ERROR",
517            "resource_type": "AWS::S3::Bucket",
518            "expression": "totally_unknown_fn(properties.BucketName)",
519            "message": "m"
520        }]}"#;
521        let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
522        assert!(
523            err.contains("R1") && err.contains("unknown function") && err.contains("totally_unknown_fn"),
524            "an unknown function reference must be rejected at load, got: {err}"
525        );
526    }
527
528    #[test]
529    fn load_custom_rules_standard_functions_and_macros_are_accepted() {
530        // has()/size()/matches() and comprehension macros must not be mistaken for
531        // unknown functions.
532        let json = r#"{"rules": [{
533            "rule_id": "R1",
534            "severity": "ERROR",
535            "expression": "has(resource.Properties) && size(resources) > 0 && [1, 2].all(x, x > 0)",
536            "message": "m"
537        }]}"#;
538        let rules = load_custom_rules(json, RuleOrigin::Custom).expect("standard functions and macros must load");
539        assert_eq!(rules.len(), 1);
540    }
541
542    #[test]
543    fn load_custom_rules_type_function_is_accepted() {
544        // `type` is registered by build_custom_context so Guard type-check operators
545        // translate to a runnable expression; it must pass the load-time check.
546        let json = r#"{"rules": [{
547            "rule_id": "R1",
548            "severity": "ERROR",
549            "resource_type": "AWS::S3::Bucket",
550            "expression": "type(resource) == \"map\"",
551            "message": "m"
552        }]}"#;
553        let rules = load_custom_rules(json, RuleOrigin::Custom).expect("type() must be an accepted function");
554        assert_eq!(rules.len(), 1);
555    }
556
557    #[test]
558    fn load_custom_rules_multiple_rules() {
559        let json = r#"{"rules": [
560            {"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": "m1"},
561            {"rule_id": "R2", "severity": "WARN", "expression": "false", "message": "m2"}
562        ]}"#;
563        let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
564        assert_eq!(rules.len(), 2);
565        assert_eq!(rules[0].rule_id, "R1");
566        assert_eq!(rules[1].rule_id, "R2");
567    }
568
569    fn single_rule(source: &str, origin: RuleOrigin) -> CustomRule {
570        load_custom_rules(source, origin).expect("rule should load").pop().expect("one rule")
571    }
572
573    #[test]
574    fn execute_custom_rule_boolean_true_fires() {
575        let rule = single_rule(
576            r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": "m"}]}"#,
577            RuleOrigin::Custom,
578        );
579        let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
580        assert!(execute_custom_rule(&rule, &ctx).expect("boolean expression evaluates"));
581    }
582
583    #[test]
584    fn execute_custom_rule_boolean_false_does_not_fire() {
585        let rule = single_rule(
586            r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "false", "message": "m"}]}"#,
587            RuleOrigin::Custom,
588        );
589        let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
590        assert!(!execute_custom_rule(&rule, &ctx).expect("boolean expression evaluates"));
591    }
592
593    #[test]
594    fn execute_custom_rule_non_boolean_result_is_error() {
595        // A rule whose expression yields a string (not a predicate) is malformed and
596        // must error rather than silently be treated as "did not fire".
597        let rule = single_rule(
598            r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "\"a string\"", "message": "m"}]}"#,
599            RuleOrigin::Custom,
600        );
601        let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
602        let err = execute_custom_rule(&rule, &ctx).expect_err("a non-boolean result must error");
603        match err {
604            ValidationError::Engine(message) => {
605                assert!(message.contains("R1") && message.contains("boolean"), "got: {message}");
606            }
607            other => panic!("expected Engine error, got {other:?}"),
608        }
609    }
610
611    #[test]
612    fn execute_custom_rule_evaluation_error_is_fatal_for_custom_origin() {
613        // A custom rule that errors at evaluation (e.g. reads a missing key) must
614        // surface the error, not swallow it.
615        let rule = single_rule(
616            r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "resource.Missing.Deep == \"x\"", "message": "m"}]}"#,
617            RuleOrigin::Custom,
618        );
619        let ctx = crate::functions::build_custom_context(&serde_json::json!({"resources": {}}), Some("Bucket"), None);
620        execute_custom_rule(&rule, &ctx).expect_err("a custom-rule evaluation error must be fatal");
621    }
622
623    #[test]
624    fn execute_custom_rule_evaluation_error_is_tolerated_for_guard_origin() {
625        // The same missing-key error is tolerated for Guard-origin rules: Guard treats an
626        // absent property as a check that does not pass, so the rule simply does not fire.
627        let rule = single_rule(
628            r#"{"rules": [{"rule_id": "G1", "severity": "ERROR", "expression": "resource.Missing.Deep == \"x\"", "message": "m"}]}"#,
629            RuleOrigin::Guard,
630        );
631        let ctx = crate::functions::build_custom_context(&serde_json::json!({"resources": {}}), Some("Bucket"), None);
632        assert!(!execute_custom_rule(&rule, &ctx).expect("guard evaluation error is tolerated as non-firing"));
633    }
634}