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