Skip to main content

rego_engine/
engine.rs

1use crate::policies;
2use data_source::embedded;
3use data_source::types::KnownResourceTypes;
4use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
5use log::{debug, info, warn};
6use rules::{Category, RuleInfo, RuleMetadataEntry, RuleOrigin, build_rule_metadata_map};
7use schema_validator::{OverlayCatalog, SchemaMetadataCatalog, SchemaValidator, schema_metadata_catalog_with_overlays};
8use std::collections::{HashMap, HashSet};
9use std::str::from_utf8;
10use std::sync::{Arc, LazyLock, Mutex};
11use template_model::SemanticModel;
12use validation_engine::{
13    EngineConfig, GuardRuleSet, ValidateConfig, ValidationEngine, ValidationError, build_rule_list,
14    extract_diagnostics_from_value, semantic_model_to_input_json,
15};
16
17static REGORUS_DATA: LazyLock<Vec<(&str, &[u8])>> = LazyLock::new(|| {
18    vec![
19        (KNOWN_RESOURCE_TYPES_PATH, &*embedded::KNOWN_RESOURCE_TYPES_BYTES),
20        (PRIMARY_IDENTIFIERS_PATH, &*embedded::PRIMARY_IDENTIFIERS_BYTES),
21        ("data/iam_action_resource_patterns", &*embedded::IAM_ACTION_RESOURCE_PATTERNS_BYTES),
22        ("data/stateful_resource_types", &*embedded::STATEFUL_RESOURCE_TYPES_BYTES),
23        ("data/aws_rds_dbinstance_dbinstanceclass_enum", &*embedded::AWS_RDS_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
24        ("data/aws_ec2_instance_instancetype_enum", &*embedded::AWS_EC2_INSTANCE_INSTANCETYPE_ENUM_BYTES),
25        (
26            "data/aws_emr_cluster_instancetypeconfig_instancetype_enum",
27            &*embedded::AWS_EMR_CLUSTER_INSTANCETYPECONFIG_INSTANCETYPE_ENUM_BYTES,
28        ),
29        ("data/aws_gamelift_fleet_ec2instancetype_enum", &*embedded::AWS_GAMELIFT_FLEET_EC2INSTANCETYPE_ENUM_BYTES),
30        ("data/aws_appstream_fleet_instancetype_enum", &*embedded::AWS_APPSTREAM_FLEET_INSTANCETYPE_ENUM_BYTES),
31        ("data/aws_dax_cluster_nodetype_enum", &*embedded::AWS_DAX_CLUSTER_NODETYPE_ENUM_BYTES),
32        ("data/aws_docdb_dbinstance_dbinstanceclass_enum", &*embedded::AWS_DOCDB_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
33        (
34            "data/aws_elasticache_cachecluster_cachenodetype_enum",
35            &*embedded::AWS_ELASTICACHE_CACHECLUSTER_CACHENODETYPE_ENUM_BYTES,
36        ),
37        (
38            "data/aws_managedblockchain_node_nodeconfiguration_instancetype_enum",
39            &*embedded::AWS_MANAGEDBLOCKCHAIN_NODE_NODECONFIGURATION_INSTANCETYPE_ENUM_BYTES,
40        ),
41        (
42            "data/aws_neptune_dbinstance_dbinstanceclass_enum",
43            &*embedded::AWS_NEPTUNE_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES,
44        ),
45        (
46            "data/aws_rds_dbcluster_dbclusterinstanceclass_enum",
47            &*embedded::AWS_RDS_DBCLUSTER_DBCLUSTERINSTANCECLASS_ENUM_BYTES,
48        ),
49        ("data/aws_rds_dbinstance_db_instance_class", &*embedded::AWS_RDS_DBINSTANCE_DB_INSTANCE_CLASS_BYTES),
50        ("data/aws_redshift_cluster_nodetype_enum", &*embedded::AWS_REDSHIFT_CLUSTER_NODETYPE_ENUM_BYTES),
51        ("data/aws_amazonmq_broker_instancetype_enum", &*embedded::AWS_AMAZONMQ_BROKER_INSTANCETYPE_ENUM_BYTES),
52        (
53            "data/aws_sagemaker_processing_instancetype_enum",
54            &*embedded::AWS_SAGEMAKER_PROCESSING_INSTANCETYPE_ENUM_BYTES,
55        ),
56        ("data/aws_sagemaker_hosting_instancetype_enum", &*embedded::AWS_SAGEMAKER_HOSTING_INSTANCETYPE_ENUM_BYTES),
57        ("data/aws_sagemaker_transform_instancetype_enum", &*embedded::AWS_SAGEMAKER_TRANSFORM_INSTANCETYPE_ENUM_BYTES),
58        ("data/aws_sagemaker_cluster_instancetype_enum", &*embedded::AWS_SAGEMAKER_CLUSTER_INSTANCETYPE_ENUM_BYTES),
59        (
60            "data/aws_elasticsearch_domain_elasticsearchclusterconfig_instancetype_enum",
61            &*embedded::AWS_ELASTICSEARCH_DOMAIN_ELASTICSEARCHCLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
62        ),
63        (
64            "data/aws_opensearchservice_domain_clusterconfig_instancetype_enum",
65            &*embedded::AWS_OPENSEARCHSERVICE_DOMAIN_CLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
66        ),
67        (GETATT_ATTRIBUTES_PATH, &*embedded::GETATT_ATTRIBUTES_BYTES),
68        ("data/codepipeline_action_artifact_counts", &*embedded::CODEPIPELINE_ACTION_ARTIFACT_COUNTS_BYTES),
69        ("data/deprecated_resource_types", &*embedded::DEPRECATED_RESOURCE_TYPES_BYTES),
70        ("data/retention_period_requirements", &*embedded::RETENTION_PERIOD_REQUIREMENTS_BYTES),
71        ("data/sensitive_ports", &*embedded::SENSITIVE_PORTS_BYTES),
72        ("data/secretsmanager_arn_fields", &*embedded::SECRETSMANAGER_ARN_FIELDS_BYTES),
73        ("data/rule_data", &*embedded::RULE_DATA_BYTES),
74        ("data/rule_tables", &*embedded::RULE_TABLES_BYTES),
75    ]
76});
77
78const CORE_PACKAGES: &[(Category, &str)] = &[
79    (Category::Structure, "data.structure.violation"),
80    (Category::Intrinsic, "data.intrinsics.violation"),
81    (Category::Reference, "data.references.violation"),
82    (Category::BestPractice, "data.best_practices.violation"),
83    (Category::Resource, "data.resources.violation"),
84];
85
86/// The [`REGORUS_DATA`] entry holding the catalog of resource types the rules
87/// treat as existing.
88const KNOWN_RESOURCE_TYPES_PATH: &str = "data/known_resource_types";
89
90/// The [`REGORUS_DATA`] entry holding GetAtt attributes and attribute types.
91const GETATT_ATTRIBUTES_PATH: &str = "data/getatt_attributes";
92
93/// The [`REGORUS_DATA`] entry holding primary identifiers per type.
94const PRIMARY_IDENTIFIERS_PATH: &str = "data/primary_identifiers";
95
96/// Re-serializes the known-resource-type catalog with `extra_types` appended, or
97/// returns `None` when there is nothing to add so the embedded bytes are used
98/// verbatim.
99fn extend_known_resource_types(extra_types: &[String]) -> anyhow::Result<Option<String>> {
100    if extra_types.is_empty() {
101        return Ok(None);
102    }
103    let mut catalog: KnownResourceTypes = serde_json::from_slice(&embedded::KNOWN_RESOURCE_TYPES_BYTES)
104        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded known_resource_types data: {e}"))?;
105    anyhow::ensure!(!catalog.known_resource_types.is_empty(), "Embedded known_resource_types data must not be empty");
106    for type_name in extra_types {
107        if !catalog.known_resource_types.contains(type_name) {
108            catalog.known_resource_types.push(type_name.clone());
109        }
110    }
111    Ok(Some(serde_json::to_string(&catalog)?))
112}
113
114/// Extends the embedded getatt_attributes data with overlay catalog entries.
115/// Returns `None` when there is nothing to add.
116fn extend_getatt_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
117    if catalog.getatt_attributes.is_empty() && catalog.getatt_attribute_types.is_empty() {
118        return Ok(None);
119    }
120    let mut data: serde_json::Value = serde_json::from_slice(&embedded::GETATT_ATTRIBUTES_BYTES)
121        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded getatt_attributes data: {e}"))?;
122
123    // Merge getatt_attributes (sort/dedup after merging)
124    if let Some(attrs_obj) = data.get_mut("getatt_attributes").and_then(|v| v.as_object_mut()) {
125        for (type_name, attrs) in &catalog.getatt_attributes {
126            let entry = attrs_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Array(Vec::new()));
127            if let Some(arr) = entry.as_array_mut() {
128                for attr in attrs {
129                    let val = serde_json::Value::String(attr.clone());
130                    if !arr.contains(&val) {
131                        arr.push(val);
132                    }
133                }
134                arr.sort_by(|a, b| a.as_str().unwrap_or("").cmp(b.as_str().unwrap_or("")));
135                arr.dedup();
136            }
137        }
138    }
139
140    // Merge getatt_attribute_types
141    if let Some(types_obj) = data.get_mut("getatt_attribute_types").and_then(|v| v.as_object_mut()) {
142        for (type_name, attr_types) in &catalog.getatt_attribute_types {
143            let entry =
144                types_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
145            if let Some(obj) = entry.as_object_mut() {
146                for (attr, atype) in attr_types {
147                    obj.insert(attr.clone(), serde_json::Value::String(atype.clone()));
148                }
149            }
150        }
151    }
152
153    Ok(Some(serde_json::to_string(&data)?))
154}
155
156/// Extends the embedded primary_identifiers data with overlay catalog entries.
157/// Returns `None` when there is nothing to add.
158fn extend_primary_identifiers_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
159    if catalog.primary_identifiers.is_empty() {
160        return Ok(None);
161    }
162    let mut data: serde_json::Value = serde_json::from_slice(&embedded::PRIMARY_IDENTIFIERS_BYTES)
163        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded primary_identifiers data: {e}"))?;
164
165    if let Some(pids_obj) = data.get_mut("primary_identifiers").and_then(|v| v.as_object_mut()) {
166        for (type_name, pids) in &catalog.primary_identifiers {
167            pids_obj.insert(type_name.clone(), serde_json::json!(pids));
168        }
169    }
170
171    Ok(Some(serde_json::to_string(&data)?))
172}
173
174/// Whether an engine instance loads and evaluates the handwritten built-in
175/// policies, or serves only caller-supplied external rules.
176///
177/// In [`BuiltinRuleMode::ExternalOnly`] the built-in policies are neither loaded
178/// nor advertised through registry metadata, so the engine evaluates only custom
179/// and Guard rules. Embedded data tables, custom builtins, and external-rule
180/// metadata are retained in both modes.
181#[derive(Clone, Copy, PartialEq, Eq)]
182enum BuiltinRuleMode {
183    Enabled,
184    ExternalOnly,
185}
186
187pub struct RegoEngine {
188    base_rego: regorus::Engine,
189    /// Whether the handwritten built-in policies are evaluated, or only the
190    /// caller-supplied external rules.
191    builtin_mode: BuiltinRuleMode,
192    /// Built-in rule metadata from the rules registry only.
193    registry_metadata: HashMap<String, RuleMetadataEntry>,
194    /// Custom rego rule metadata discovered from evaluation output.
195    /// Rego rules embed metadata in their output objects, so it can only
196    /// be extracted after evaluation. May be incomplete if not all rules fired.
197    discovered_custom_metadata: Mutex<HashMap<String, RuleMetadataEntry>>,
198    custom_packages: Vec<String>,
199    /// Guard rules are evaluated by the shared Guard evaluator, not by Rego, so
200    /// every engine reports the same Guard findings.
201    guard_rules: Option<GuardRuleSet>,
202    init_metric: PhaseMetric,
203}
204
205impl RegoEngine {
206    pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
207        let overlay_catalog =
208            config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
209        let start = web_time::Instant::now();
210        let schema_metadata = schema_metadata_catalog_with_overlays(&overlay_catalog)?;
211        Self::new_from_parts(config, &overlay_catalog, schema_metadata, BuiltinRuleMode::Enabled, start)
212    }
213
214    /// Constructs the engine reusing metadata from an already-built
215    /// [`SchemaValidator`](schema_validator::SchemaValidator). The validator's
216    /// overlay catalog is treated as authoritative - the engine does not
217    /// re-resolve overlay schemas.
218    ///
219    /// This entry point is intended for language bindings and the CLI, which
220    /// construct a `SchemaValidator` once and share it with the engine. The
221    /// validator's shared schema-metadata catalog is reused rather than rebuilt,
222    /// so every engine built from the same validator shares one catalog rather
223    /// than rebuilding it.
224    #[doc(hidden)]
225    pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
226        let start = web_time::Instant::now();
227        let schema_metadata = validator.schema_metadata_catalog()?;
228        Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, BuiltinRuleMode::Enabled, start)
229    }
230
231    /// Constructs an engine that evaluates only the caller-supplied custom and
232    /// Guard rules, without loading or advertising the handwritten built-in
233    /// policies. Embedded data tables and custom builtins remain available so
234    /// external rules can call them.
235    ///
236    /// This backs the composite engine, whose built-in rules are owned by a
237    /// separate engine; loading them here too would double-evaluate them.
238    pub fn new_external_only(config: EngineConfig) -> anyhow::Result<Self> {
239        let overlay_catalog =
240            config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
241        let start = web_time::Instant::now();
242        let schema_metadata = schema_metadata_catalog_with_overlays(&overlay_catalog)?;
243        Self::new_from_parts(config, &overlay_catalog, schema_metadata, BuiltinRuleMode::ExternalOnly, start)
244    }
245
246    /// External-only counterpart to [`RegoEngine::new_with_schema_validator`],
247    /// reusing an already-built validator's overlay catalog and shared
248    /// schema-metadata catalog.
249    #[doc(hidden)]
250    pub fn new_external_only_with_schema_validator(
251        config: EngineConfig,
252        validator: &SchemaValidator,
253    ) -> anyhow::Result<Self> {
254        let start = web_time::Instant::now();
255        let schema_metadata = validator.schema_metadata_catalog()?;
256        Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, BuiltinRuleMode::ExternalOnly, start)
257    }
258
259    /// Internal constructor that accepts a pre-built overlay catalog and the
260    /// shared schema-metadata catalog resolved for it.
261    fn new_from_parts(
262        config: EngineConfig,
263        overlay_catalog: &OverlayCatalog,
264        schema_metadata: Arc<SchemaMetadataCatalog>,
265        builtin_mode: BuiltinRuleMode,
266        start: web_time::Instant,
267    ) -> anyhow::Result<Self> {
268        let mut rego = regorus::Engine::new();
269        rego.set_strict_builtin_errors(false);
270
271        // Single-pass merge avoids per-file JSON parsing overhead.
272        {
273            // Resource types introduced by an overlay schema are legitimate
274            // targets, so the type catalog the rules consult must include them
275            // rather than reporting them as nonexistent.
276            let overlay_types: Vec<String> = overlay_catalog.type_names.clone();
277            let extended_known_types = extend_known_resource_types(&overlay_types)?;
278
279            // Extend getatt_attributes data with overlay entries
280            let extended_getatt = extend_getatt_data(overlay_catalog)?;
281            // Extend primary_identifiers data with overlay entries
282            let extended_primary_ids = extend_primary_identifiers_data(overlay_catalog)?;
283
284            // Embedded lengths closely size the common no-overlay path; extended
285            // overlay documents can grow the buffer normally when they are larger.
286            let merged_capacity: usize = REGORUS_DATA.iter().map(|(_, json_bytes)| json_bytes.len()).sum();
287            let mut merged = String::with_capacity(merged_capacity);
288            merged.push('{');
289            for (i, (path, json_bytes)) in REGORUS_DATA.iter().enumerate() {
290                let json_str = match (
291                    *path,
292                    extended_known_types.as_deref(),
293                    extended_getatt.as_deref(),
294                    extended_primary_ids.as_deref(),
295                ) {
296                    (KNOWN_RESOURCE_TYPES_PATH, Some(extended), _, _) => extended,
297                    (GETATT_ATTRIBUTES_PATH, _, Some(extended), _) => extended,
298                    (PRIMARY_IDENTIFIERS_PATH, _, _, Some(extended)) => extended,
299                    _ => from_utf8(json_bytes).map_err(|error| {
300                        anyhow::anyhow!("Embedded JSON data '{}' is not valid UTF-8: {}", path, error)
301                    })?,
302                };
303                let inner = json_str
304                    .trim()
305                    .strip_prefix('{')
306                    .and_then(|value| value.strip_suffix('}'))
307                    .ok_or_else(|| anyhow::anyhow!("Embedded JSON data '{}' must be a top-level object", path))?;
308                anyhow::ensure!(!inner.trim().is_empty(), "Embedded JSON data '{}' must not be empty", path);
309                if i > 0 {
310                    merged.push(',');
311                }
312                merged.push_str(inner);
313            }
314            merged.push('}');
315            rego.add_data(regorus::Value::from_json_str(&merged)?)?;
316        }
317
318        // In external-only mode the built-in policies are owned by a separate
319        // engine, so they are neither loaded nor evaluated here. Embedded data,
320        // custom builtins, custom Rego, and Guard rules are still loaded below
321        // regardless of mode.
322        let handwritten_rule_count = match builtin_mode {
323            BuiltinRuleMode::Enabled => {
324                for (path, source) in policies::HANDWRITTEN_REGO_POLICIES {
325                    rego.add_policy(path.to_string(), source.to_string())?;
326                }
327                policies::HANDWRITTEN_REGO_POLICIES.len()
328            }
329            BuiltinRuleMode::ExternalOnly => 0,
330        };
331        debug!("Loaded {} data files, {} handwritten rules", REGORUS_DATA.len(), handwritten_rule_count);
332
333        let guard_rules = GuardRuleSet::compile(&config.guard_rules).map_err(|error| anyhow::anyhow!(error))?;
334
335        for entry in &config.custom_rules {
336            rego.add_policy(entry.name.clone(), entry.content.clone())?;
337        }
338        let mut custom_packages = Vec::new();
339        for entry in &config.custom_rules {
340            for line in entry.content.lines() {
341                let trimmed = line.trim();
342                if let Some(pkg) = trimmed.strip_prefix("package ") {
343                    let eval_path = format!("data.{}.violation", pkg.trim());
344                    if !custom_packages.contains(&eval_path) {
345                        custom_packages.push(eval_path);
346                    }
347                }
348            }
349        }
350
351        crate::builtins::register_all(&mut rego, overlay_catalog, schema_metadata)?;
352
353        // External-only construction advertises no built-in rules; their
354        // metadata belongs to the engine that owns the built-in policies.
355        let registry_metadata = match builtin_mode {
356            BuiltinRuleMode::Enabled => build_rule_metadata_map(),
357            BuiltinRuleMode::ExternalOnly => HashMap::new(),
358        };
359
360        // Warming up the aggregate compiles the built-in policies once so the
361        // first real evaluation is not charged that cost. The aggregate only
362        // exists when the built-in policies are loaded.
363        if matches!(builtin_mode, BuiltinRuleMode::Enabled) {
364            rego.set_input(regorus::Value::new_object());
365            let _ = rego.eval_rule("data.all_violations.violation".to_string());
366        }
367
368        info!(
369            "RegoEngine initialized: {} handwritten rules, {} data files, {} registry + {} Guard metadata entries",
370            handwritten_rule_count,
371            REGORUS_DATA.len(),
372            registry_metadata.len(),
373            guard_rules.as_ref().map_or(0, |guard| guard.rule_metadata().len())
374        );
375        let init_metric = phase_metric(start);
376        Ok(RegoEngine {
377            base_rego: rego,
378            builtin_mode,
379            registry_metadata,
380            discovered_custom_metadata: Mutex::new(HashMap::new()),
381            custom_packages,
382            guard_rules,
383            init_metric,
384        })
385    }
386
387    /// Evaluates a single Rego package and appends its diagnostics to `out`.
388    ///
389    /// Any evaluation or serialization failure is returned as a structured
390    /// [`ValidationError`] - an exception the caller can handle - and is never
391    /// converted into a diagnostic. A rule that fails to run must surface as an
392    /// error, not masquerade as a finding.
393    fn eval_package_into(
394        &self,
395        rego: &mut regorus::Engine,
396        package: &str,
397        source_label: &str,
398        model: &SemanticModel,
399        origin: Option<&RuleOrigin>,
400        out: &mut Vec<Diagnostic>,
401    ) -> Result<(), ValidationError> {
402        let value = rego.eval_rule(package.to_string()).map_err(|e| {
403            ValidationError::Engine(format!("{source_label} rule package '{package}' failed to evaluate: {e}"))
404        })?;
405        let diagnostics_json = serde_json::to_value(&value).map_err(|e| {
406            ValidationError::Engine(format!(
407                "{source_label} rule package '{package}' produced a result that could not be \
408                 serialized to JSON: {e}"
409            ))
410        })?;
411        extract_diagnostics_from_value(&diagnostics_json, model, out, origin).map_err(ValidationError::from)
412    }
413
414    /// The built-in rule IDs that global filtering proves cannot survive under
415    /// `config`, so their handwritten clauses can stop at the `cfn_rule_active`
416    /// guard before doing any work. Only rules the registry defines are eligible;
417    /// custom and guard rules are never globally suppressed here.
418    fn globally_suppressed_builtin_rules(&self, config: &ValidateConfig) -> Arc<HashSet<String>> {
419        let suppressed: HashSet<String> = self
420            .registry_metadata
421            .iter()
422            .filter(|(rule_id, entry)| {
423                config.filters.globally_suppresses_builtin_rule(
424                    rule_id,
425                    entry.category.as_deref(),
426                    entry.severity,
427                    config.strict,
428                    config.severity_level,
429                )
430            })
431            .map(|(rule_id, _)| rule_id.clone())
432            .collect();
433        Arc::new(suppressed)
434    }
435}
436
437impl ValidationEngine for RegoEngine {
438    fn engine_name(&self) -> &str {
439        "rego"
440    }
441
442    fn evaluate_rules(
443        &self,
444        model: &Arc<SemanticModel>,
445        config: &ValidateConfig,
446    ) -> Result<Vec<Diagnostic>, ValidationError> {
447        let context = crate::eval_context::EvaluationContext::new(
448            model.clone(),
449            config.pseudo_parameter_overrides.region.clone(),
450            self.globally_suppressed_builtin_rules(config),
451        );
452        let _scope = crate::eval_context::EvaluationScope::enter(context);
453
454        let mut rego = self.base_rego.clone();
455
456        let input_json = semantic_model_to_input_json(model)?;
457        let input_value = crate::builtins::serde_json_to_rego_value(&input_json);
458        rego.set_input(input_value);
459
460        let mut diagnostics = Vec::new();
461
462        // Core built-in policies run only when this engine owns them and the
463        // caller has not disabled built-ins. The custom and Guard evaluation
464        // below runs unconditionally so external rules are always evaluated.
465        let evaluate_builtins = matches!(self.builtin_mode, BuiltinRuleMode::Enabled) && !config.disable_builtin_rules;
466        if evaluate_builtins {
467            let excluded_cats = config.filters.excluded_categories();
468
469            let needed_core: Vec<&str> = CORE_PACKAGES
470                .iter()
471                .filter(|(cat, _)| !excluded_cats.contains(cat.as_str()))
472                .map(|(_, pkg)| *pkg)
473                .collect();
474
475            if needed_core.len() == CORE_PACKAGES.len() {
476                match rego.eval_rule("data.all_violations.violation".to_string()) {
477                    Ok(val) => {
478                        let diagnostics_json = serde_json::to_value(&val).map_err(|e| {
479                            ValidationError::Engine(format!(
480                                "Aggregated rule evaluation produced a result that could not be \
481                             serialized to JSON: {e}"
482                            ))
483                        })?;
484                        extract_diagnostics_from_value(&diagnostics_json, model, &mut diagnostics, None)
485                            .map_err(ValidationError::from)?;
486                    }
487                    Err(e) => {
488                        warn!("Aggregated eval failed ({}), falling back to individual packages", e);
489                        for pkg in &needed_core {
490                            self.eval_package_into(&mut rego, pkg, "Core", model, None, &mut diagnostics)?;
491                        }
492                    }
493                }
494            } else {
495                debug!("Skipping excluded categories: {:?}", excluded_cats);
496                for pkg in &needed_core {
497                    self.eval_package_into(&mut rego, pkg, "Core", model, None, &mut diagnostics)?;
498                }
499            }
500        }
501
502        for pkg in &self.custom_packages {
503            self.eval_package_into(&mut rego, pkg, "Custom", model, Some(&RuleOrigin::Custom), &mut diagnostics)?;
504        }
505
506        if let Some(guard_rules) = &self.guard_rules {
507            diagnostics.extend(guard_rules.evaluate(model)?);
508        }
509
510        if !self.custom_packages.is_empty() {
511            let mut discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
512            for d in &diagnostics {
513                if d.source == RuleOrigin::Custom {
514                    discovered.entry(d.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
515                        category: d.category.clone(),
516                        description: d.message.clone(),
517                        severity: d.severity,
518                        origin: RuleOrigin::Custom,
519                    });
520                }
521            }
522        }
523
524        Ok(diagnostics)
525    }
526
527    fn list_rules(&self) -> Vec<RuleInfo> {
528        build_rule_list(&self.registry_metadata, &self.external_rule_metadata())
529    }
530
531    fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
532        &self.registry_metadata
533    }
534
535    /// Returns Guard rule metadata merged with any custom Rego rule metadata
536    /// discovered from prior evaluations. Custom diagnostics already carry their
537    /// current evaluation's rule description, so this accumulated metadata cannot
538    /// overwrite a later report.
539    fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
540        let mut merged = self.guard_rules.as_ref().map(|guard| guard.rule_metadata().clone()).unwrap_or_default();
541        if !self.custom_packages.is_empty() {
542            let discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
543            merged.extend(discovered.iter().map(|(k, v)| (k.clone(), v.clone())));
544        }
545        merged
546    }
547
548    fn init_metric(&self) -> &PhaseMetric {
549        &self.init_metric
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556    use diagnostics::DetailLevel;
557    use rules::{FilterConfig, RuleFilterConfig, Severity};
558    use std::sync::Barrier;
559    use std::thread;
560    use template_model::SemanticModel;
561    use validation_engine::{EngineConfig, ExternalRuleSource, ValidateConfig, ValidationEngine, validate_bytes};
562
563    fn make_engine() -> RegoEngine {
564        RegoEngine::new(EngineConfig::default()).unwrap()
565    }
566
567    fn make_model_from_yaml(yaml: &str) -> Arc<SemanticModel> {
568        let model = SemanticModel::from_bytes(yaml.as_bytes()).unwrap();
569        Arc::new(model)
570    }
571
572    #[test]
573    fn new_default_config_succeeds() {
574        let engine = make_engine();
575        assert_eq!(engine.engine_name(), "rego");
576    }
577
578    #[test]
579    fn list_rules_returns_nonempty() {
580        let engine = make_engine();
581        let rules = engine.list_rules();
582        assert!(!rules.is_empty(), "should have built-in rules");
583    }
584
585    #[test]
586    fn rule_metadata_contains_entries() {
587        let engine = make_engine();
588        assert!(!engine.rule_metadata().is_empty());
589    }
590
591    #[test]
592    fn init_metric_has_positive_duration() {
593        let engine = make_engine();
594        let metric = engine.init_metric();
595        assert!(metric.duration_ms > 0.0, "init duration should be > 0, got {}", metric.duration_ms);
596    }
597
598    #[test]
599    fn evaluate_minimal_template_no_errors() {
600        let engine = make_engine();
601        let model = make_model_from_yaml(
602            r#"
603AWSTemplateFormatVersion: "2010-09-09"
604Resources:
605  MyBucket:
606    Type: AWS::S3::Bucket
607"#,
608        );
609        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
610        assert!(
611            diags.iter().all(|d| d.severity != Severity::Fatal),
612            "rego engine should not produce Fatal diagnostics"
613        );
614    }
615
616    #[test]
617    fn evaluate_empty_resources_does_not_panic() {
618        let engine = make_engine();
619        let model = make_model_from_yaml(
620            r#"
621AWSTemplateFormatVersion: "2010-09-09"
622Resources: {}
623"#,
624        );
625        // CloudFormation rejects templates with empty Resources, so the empty-resources diagnostic is expected.
626        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
627        assert!(
628            diags.iter().any(|d| d.rule_id == "F0001"),
629            "expected F0001 Fatal for empty Resources section, got: {:?}",
630            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>(),
631        );
632    }
633
634    #[test]
635    fn evaluate_with_excluded_categories() {
636        let engine = make_engine();
637        let model = make_model_from_yaml(
638            r#"
639AWSTemplateFormatVersion: "2010-09-09"
640Resources:
641  MyBucket:
642    Type: AWS::S3::Bucket
643"#,
644        );
645        let config = ValidateConfig {
646            filters: FilterConfig::new(
647                RuleFilterConfig::default(),
648                RuleFilterConfig { categories: vec!["best_practices".to_string()], ..Default::default() },
649            ),
650            ..Default::default()
651        };
652        let diags_filtered = engine.evaluate_rules(&model, &config).unwrap();
653
654        let diags_all = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
655
656        assert!(
657            diags_filtered.len() <= diags_all.len(),
658            "filtered count {} should be <= unfiltered count {}",
659            diags_filtered.len(),
660            diags_all.len()
661        );
662    }
663
664    #[test]
665    fn custom_rego_rule_produces_diagnostics() {
666        let custom_rego = r#"
667package custom_test
668import rego.v1
669
670violation contains v if {
671    some name, res in input.resources
672    res.resourceType == "AWS::S3::Bucket"
673    v := {"rule_id": "CUSTOM001", "severity": "error", "message": "Custom rule fired", "resource_id": name}
674}
675"#;
676        let config = EngineConfig {
677            custom_rules: vec![ExternalRuleSource { name: "custom_test.rego".into(), content: custom_rego.into() }],
678            guard_rules: vec![],
679            ..Default::default()
680        };
681        let engine = RegoEngine::new(config).unwrap();
682        let model = make_model_from_yaml(
683            r#"
684AWSTemplateFormatVersion: "2010-09-09"
685Resources:
686  MyBucket:
687    Type: AWS::S3::Bucket
688"#,
689        );
690        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
691        let custom = diags.iter().find(|d| d.rule_id == "CUSTOM001");
692        assert!(custom.is_some(), "custom rule should fire");
693    }
694
695    #[test]
696    fn guard_rule_produces_diagnostics() {
697        let guard_source = r#"
698rule check_bucket_name {
699    AWS::S3::Bucket {
700        Properties.BucketName EXISTS
701        <<BucketName must be specified>>
702    }
703}
704"#;
705        let config = EngineConfig {
706            custom_rules: vec![],
707            guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
708            ..Default::default()
709        };
710        let engine = RegoEngine::new(config).unwrap();
711        let model = make_model_from_yaml(
712            r#"
713AWSTemplateFormatVersion: "2010-09-09"
714Resources:
715  MyBucket:
716    Type: AWS::S3::Bucket
717"#,
718        );
719        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
720        let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name").expect("guard rule fires");
721        assert_eq!(guard_diag.source, RuleOrigin::Guard);
722        assert_eq!(guard_diag.category.as_deref(), Some("guard:test"));
723        assert_eq!(guard_diag.message, "BucketName must be specified");
724        assert_eq!(guard_diag.resource_logical_id(), Some("MyBucket"));
725    }
726
727    #[test]
728    fn guard_rule_exists_check_does_not_fire_when_property_is_present() {
729        let guard_source = r#"
730rule check_bucket_name {
731    AWS::S3::Bucket {
732        Properties.BucketName EXISTS
733        <<BucketName must be specified>>
734    }
735}
736"#;
737        let config = EngineConfig {
738            custom_rules: vec![],
739            guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
740            ..Default::default()
741        };
742        let engine = RegoEngine::new(config).unwrap();
743        let model = make_model_from_yaml(
744            r#"
745AWSTemplateFormatVersion: "2010-09-09"
746Resources:
747  MyBucket:
748    Type: AWS::S3::Bucket
749    Properties:
750      BucketName: my-bucket
751"#,
752        );
753        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
754        let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name");
755        assert!(
756            guard_diag.is_none(),
757            "guard EXISTS must be satisfied when BucketName is present, so the rule must not fire; got: {:?}",
758            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
759        );
760    }
761
762    #[test]
763    fn external_only_engine_evaluates_external_rules_but_skips_builtins() {
764        let custom_rego = r#"
765package external_only_test
766import rego.v1
767
768violation contains v if {
769    v := {"rule_id": "EXT_ONLY", "severity": "error", "message": "external rule fired", "resource_id": ""}
770}
771"#;
772        let config = EngineConfig {
773            custom_rules: vec![ExternalRuleSource {
774                name: "external_only_test.rego".into(),
775                content: custom_rego.into(),
776            }],
777            guard_rules: vec![],
778            ..Default::default()
779        };
780        let engine = RegoEngine::new_external_only(config).unwrap();
781        assert!(engine.rule_metadata().is_empty(), "an external-only engine must advertise no built-in registry rules");
782
783        // An empty Resources section makes a normal engine emit a built-in
784        // structural diagnostic, so its absence proves the built-ins were
785        // skipped rather than simply not triggered.
786        let model = make_model_from_yaml(
787            r#"
788AWSTemplateFormatVersion: "2010-09-09"
789Resources: {}
790"#,
791        );
792        let normal_diags = RegoEngine::new(EngineConfig::default())
793            .unwrap()
794            .evaluate_rules(&model, &ValidateConfig::default())
795            .unwrap();
796        assert!(
797            normal_diags.iter().any(|d| d.rule_id == "F0001"),
798            "a normal engine must emit the built-in F0001 for empty Resources"
799        );
800
801        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
802        let external = diags.iter().find(|d| d.rule_id == "EXT_ONLY").expect("the external rule must still evaluate");
803        assert_eq!(external.severity, Severity::Error);
804        assert_eq!(external.source, RuleOrigin::Custom);
805        assert!(
806            diags.iter().all(|d| d.rule_id == "EXT_ONLY"),
807            "external-only evaluation must produce no built-in diagnostics, got: {:?}",
808            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
809        );
810        assert!(
811            !engine.list_rules().iter().any(|r| r.id == "F0001"),
812            "external-only list_rules must not include built-in rule IDs"
813        );
814    }
815
816    #[test]
817    fn external_only_engine_registers_custom_builtin_helpers() {
818        let custom_rego = r#"
819package external_builtin_test
820import rego.v1
821
822violation contains make_diag("EXT_BUILTINS", "error", name, "custom builtins available") if {
823    some name in resources_of_type("AWS::S3::Bucket")
824    has_property(name, "BucketName")
825    resolve(name, "Properties.BucketName") == "custom-builtins-bucket"
826    "BucketName" in schema_properties("AWS::S3::Bucket")
827}
828"#;
829        let engine = RegoEngine::new_external_only(EngineConfig {
830            custom_rules: vec![ExternalRuleSource {
831                name: "external_builtin_test.rego".into(),
832                content: custom_rego.into(),
833            }],
834            ..Default::default()
835        })
836        .expect("external-only engine must initialize with custom builtins");
837        let model = make_model_from_yaml(
838            r#"
839Resources:
840  Bucket:
841    Type: AWS::S3::Bucket
842    Properties:
843      BucketName: custom-builtins-bucket
844"#,
845        );
846
847        let diagnostics = engine.evaluate_rules(&model, &ValidateConfig::default()).expect("custom rule must evaluate");
848        let finding = diagnostics
849            .iter()
850            .find(|diagnostic| diagnostic.rule_id == "EXT_BUILTINS")
851            .expect("custom rule using resource, resolution, schema, and diagnostic helpers must fire");
852        assert_eq!(finding.source, RuleOrigin::Custom);
853        assert_eq!(finding.resource_logical_id(), Some("Bucket"));
854    }
855
856    #[test]
857    fn evaluate_rules_can_be_called_repeatedly() {
858        let engine = make_engine();
859        let model = make_model_from_yaml(
860            r#"
861AWSTemplateFormatVersion: "2010-09-09"
862Resources:
863  A:
864    Type: AWS::S3::Bucket
865"#,
866        );
867        // The per-thread evaluation context is installed and torn down each call,
868        // so repeated evaluations on one thread neither deadlock nor leak state.
869        let _ = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
870        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
871        assert!(
872            diags.iter().all(|d| d.severity != Severity::Fatal),
873            "rego engine should not produce Fatal diagnostics"
874        );
875    }
876
877    #[test]
878    fn build_rule_metadata_map_returns_entries() {
879        let index = build_rule_metadata_map();
880        assert!(!index.is_empty());
881    }
882
883    const BUILTIN_TEST_TEMPLATE: &str = r#"
884AWSTemplateFormatVersion: "2010-09-09"
885Parameters:
886  Env:
887    Type: String
888    AllowedValues:
889      - prod
890      - dev
891  Port:
892    Type: Number
893Conditions:
894  IsProd:
895    Fn::Equals:
896      - !Ref Env
897      - prod
898Mappings:
899  RegionMap:
900    us-east-1:
901      AMI: ami-12345678
902    us-west-2:
903      AMI: ami-abcdefgh
904Resources:
905  MyBucket:
906    Type: AWS::S3::Bucket
907    Properties:
908      BucketName: my-bucket
909      Tags:
910        - Key: Env
911          Value: !If [IsProd, "production", "development"]
912  MyFunc:
913    Type: AWS::Lambda::Function
914    DependsOn: MyBucket
915    Condition: IsProd
916    Properties:
917      FunctionName: my-func
918      Runtime: python3.12
919      Handler: index.handler
920      Code:
921        S3Bucket: !Ref MyBucket
922        S3Key: code.zip
923  MyQueue:
924    Type: AWS::SQS::Queue
925    Properties:
926      QueueName: my-queue
927Transform: AWS::Serverless-2016-10-31
928"#;
929
930    fn eval_builtin_policy(rego_source: &str, rule_id: &str) -> Vec<Diagnostic> {
931        let config = EngineConfig {
932            custom_rules: vec![ExternalRuleSource { name: "builtin_test.rego".into(), content: rego_source.into() }],
933            guard_rules: vec![],
934            ..Default::default()
935        };
936        let engine = RegoEngine::new(config).unwrap();
937        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
938        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
939        diags.into_iter().filter(|d| d.rule_id == rule_id).collect()
940    }
941
942    #[test]
943    fn builtin_resolve_returns_property_value() {
944        let diags = eval_builtin_policy(
945            r#"
946package builtin_test
947import rego.v1
948violation contains v if {
949    val := resolve("MyBucket", "BucketName")
950    val == "my-bucket"
951    v := {"rule_id": "B_RESOLVE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
952}
953"#,
954            "B_RESOLVE",
955        );
956        assert_eq!(diags.len(), 1, "resolve should return 'my-bucket'");
957    }
958
959    #[test]
960    fn builtin_resolve_all_returns_conditional_values() {
961        let diags = eval_builtin_policy(
962            r#"
963package builtin_test
964import rego.v1
965violation contains v if {
966    vals := resolve_all("MyBucket", "Tags.0.Value")
967    count(vals) >= 1
968    v := {"rule_id": "B_RESOLVE_ALL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
969}
970"#,
971            "B_RESOLVE_ALL",
972        );
973        assert_eq!(diags.len(), 1, "resolve_all should return at least one value");
974    }
975
976    #[test]
977    fn builtin_is_dynamic_on_conditional_property() {
978        let diags = eval_builtin_policy(
979            r#"
980package builtin_test
981import rego.v1
982violation contains v if {
983    is_dynamic("MyFunc", "Code.S3Bucket")
984    v := {"rule_id": "B_IS_DYN", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
985}
986"#,
987            "B_IS_DYN",
988        );
989        assert_eq!(diags.len(), 1, "Code.S3Bucket is a Ref → dynamic");
990    }
991
992    #[test]
993    fn builtin_is_dynamic_false_for_static() {
994        let diags = eval_builtin_policy(
995            r#"
996package builtin_test
997import rego.v1
998violation contains v if {
999    not is_dynamic("MyBucket", "BucketName")
1000    v := {"rule_id": "B_NOT_DYN", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1001}
1002"#,
1003            "B_NOT_DYN",
1004        );
1005        assert_eq!(diags.len(), 1, "BucketName is static");
1006    }
1007
1008    #[test]
1009    fn builtin_resources_of_type() {
1010        let diags = eval_builtin_policy(
1011            r#"
1012package builtin_test
1013import rego.v1
1014violation contains v if {
1015    ids := resources_of_type("AWS::S3::Bucket")
1016    count(ids) == 1
1017    v := {"rule_id": "B_ROT", "severity": "error", "message": "ok", "resource_id": ""}
1018}
1019"#,
1020            "B_ROT",
1021        );
1022        assert_eq!(diags.len(), 1, "should find 1 S3 bucket");
1023    }
1024
1025    #[test]
1026    fn builtin_has_property_true() {
1027        let diags = eval_builtin_policy(
1028            r#"
1029package builtin_test
1030import rego.v1
1031violation contains v if {
1032    has_property("MyBucket", "BucketName")
1033    v := {"rule_id": "B_HAS_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1034}
1035"#,
1036            "B_HAS_P",
1037        );
1038        assert_eq!(diags.len(), 1, "MyBucket has BucketName");
1039    }
1040
1041    #[test]
1042    fn builtin_has_property_false() {
1043        let diags = eval_builtin_policy(
1044            r#"
1045package builtin_test
1046import rego.v1
1047violation contains v if {
1048    not has_property("MyBucket", "NonExistent")
1049    v := {"rule_id": "B_NO_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1050}
1051"#,
1052            "B_NO_P",
1053        );
1054        assert_eq!(diags.len(), 1, "MyBucket does not have NonExistent");
1055    }
1056
1057    #[test]
1058    fn builtin_has_transform() {
1059        let diags = eval_builtin_policy(
1060            r#"
1061package builtin_test
1062import rego.v1
1063violation contains v if {
1064    has_transform("AWS::Serverless-2016-10-31")
1065    v := {"rule_id": "B_HAS_T", "severity": "error", "message": "ok", "resource_id": ""}
1066}
1067"#,
1068            "B_HAS_T",
1069        );
1070        assert_eq!(diags.len(), 1, "template has Serverless transform");
1071    }
1072
1073    #[test]
1074    fn builtin_param_allowed_values() {
1075        let diags = eval_builtin_policy(
1076            r#"
1077package builtin_test
1078import rego.v1
1079violation contains v if {
1080    vals := param_allowed_values("Env")
1081    count(vals) == 2
1082    v := {"rule_id": "B_PAV", "severity": "error", "message": "ok", "resource_id": ""}
1083}
1084"#,
1085            "B_PAV",
1086        );
1087        assert_eq!(diags.len(), 1, "Env has 2 allowed values");
1088    }
1089
1090    #[test]
1091    fn builtin_param_type() {
1092        let diags = eval_builtin_policy(
1093            r#"
1094package builtin_test
1095import rego.v1
1096violation contains v if {
1097    t := param_type("Port")
1098    t == "Number"
1099    v := {"rule_id": "B_PT", "severity": "error", "message": "ok", "resource_id": ""}
1100}
1101"#,
1102            "B_PT",
1103        );
1104        assert_eq!(diags.len(), 1, "Port param type is Number");
1105    }
1106
1107    #[test]
1108    fn builtin_mapping_value() {
1109        let diags = eval_builtin_policy(
1110            r#"
1111package builtin_test
1112import rego.v1
1113violation contains v if {
1114    val := mapping_value("RegionMap", "us-east-1", "AMI")
1115    val == "ami-12345678"
1116    v := {"rule_id": "B_MAP", "severity": "error", "message": "ok", "resource_id": ""}
1117}
1118"#,
1119            "B_MAP",
1120        );
1121        assert_eq!(diags.len(), 1, "mapping lookup should return ami-12345678");
1122    }
1123
1124    #[test]
1125    fn builtin_resource_condition() {
1126        let diags = eval_builtin_policy(
1127            r#"
1128package builtin_test
1129import rego.v1
1130violation contains v if {
1131    c := resource_condition("MyFunc")
1132    c == "IsProd"
1133    v := {"rule_id": "B_RC", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1134}
1135"#,
1136            "B_RC",
1137        );
1138        assert_eq!(diags.len(), 1, "MyFunc has condition IsProd");
1139    }
1140
1141    #[test]
1142    fn builtin_resource_condition_null_when_none() {
1143        let diags = eval_builtin_policy(
1144            r#"
1145package builtin_test
1146import rego.v1
1147violation contains v if {
1148    c := resource_condition("MyBucket")
1149    is_null(c)
1150    v := {"rule_id": "B_RC_NULL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1151}
1152"#,
1153            "B_RC_NULL",
1154        );
1155        assert_eq!(diags.len(), 1, "MyBucket has no condition");
1156    }
1157
1158    #[test]
1159    fn builtin_get_resource() {
1160        let diags = eval_builtin_policy(
1161            r#"
1162package builtin_test
1163import rego.v1
1164violation contains v if {
1165    res := get_resource("MyBucket")
1166    res.resourceType == "AWS::S3::Bucket"
1167    v := {"rule_id": "B_GR", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1168}
1169"#,
1170            "B_GR",
1171        );
1172        assert_eq!(diags.len(), 1, "get_resource should return bucket data");
1173    }
1174
1175    #[test]
1176    fn builtin_depends_on() {
1177        let diags = eval_builtin_policy(
1178            r#"
1179package builtin_test
1180import rego.v1
1181violation contains v if {
1182    depends_on("MyFunc", "MyBucket")
1183    v := {"rule_id": "B_DEP", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1184}
1185"#,
1186            "B_DEP",
1187        );
1188        assert_eq!(diags.len(), 1, "MyFunc depends on MyBucket");
1189    }
1190
1191    #[test]
1192    fn builtin_ref_targets() {
1193        let diags = eval_builtin_policy(
1194            r#"
1195package builtin_test
1196import rego.v1
1197violation contains v if {
1198    targets := ref_targets("MyFunc")
1199    count(targets) > 0
1200    v := {"rule_id": "B_RT", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1201}
1202"#,
1203            "B_RT",
1204        );
1205        assert_eq!(diags.len(), 1, "MyFunc should have ref targets (MyBucket)");
1206    }
1207
1208    #[test]
1209    fn builtin_ref_sources() {
1210        let diags = eval_builtin_policy(
1211            r#"
1212package builtin_test
1213import rego.v1
1214violation contains v if {
1215    sources := ref_sources("MyBucket")
1216    count(sources) > 0
1217    v := {"rule_id": "B_RS", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1218}
1219"#,
1220            "B_RS",
1221        );
1222        assert_eq!(diags.len(), 1, "MyBucket should have ref sources (MyFunc)");
1223    }
1224
1225    #[test]
1226    fn builtin_edges_from() {
1227        let diags = eval_builtin_policy(
1228            r#"
1229package builtin_test
1230import rego.v1
1231violation contains v if {
1232    edges := edges_from("MyFunc")
1233    count(edges) > 0
1234    v := {"rule_id": "B_EF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1235}
1236"#,
1237            "B_EF",
1238        );
1239        assert_eq!(diags.len(), 1, "MyFunc should have outgoing edges");
1240    }
1241
1242    #[test]
1243    fn builtin_edges_to() {
1244        let diags = eval_builtin_policy(
1245            r#"
1246package builtin_test
1247import rego.v1
1248violation contains v if {
1249    edges := edges_to("MyBucket")
1250    count(edges) > 0
1251    v := {"rule_id": "B_ET", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1252}
1253"#,
1254            "B_ET",
1255        );
1256        assert_eq!(diags.len(), 1, "MyBucket should have incoming edges");
1257    }
1258
1259    #[test]
1260    fn builtin_make_diag_at() {
1261        let diags = eval_builtin_policy(
1262            r#"
1263package builtin_test
1264import rego.v1
1265violation contains v if {
1266    v := make_diag_at("B_MDA", "error", "MyBucket", "BucketName", "test diag at")
1267}
1268"#,
1269            "B_MDA",
1270        );
1271        assert_eq!(diags.len(), 1);
1272        assert_eq!(diags[0].message, "test diag at");
1273    }
1274
1275    #[test]
1276    fn builtin_make_diag_full() {
1277        let diags = eval_builtin_policy(
1278            r#"
1279package builtin_test
1280import rego.v1
1281violation contains v if {
1282    v := make_diag_full("B_MDF", "error", "MyBucket", "BucketName", "full diag", "fix it", "https://example.com")
1283}
1284"#,
1285            "B_MDF",
1286        );
1287        assert_eq!(diags.len(), 1);
1288        assert_eq!(diags[0].message, "full diag");
1289    }
1290
1291    #[test]
1292    fn builtin_schema_properties_known_type() {
1293        let diags = eval_builtin_policy(
1294            r#"
1295package builtin_test
1296import rego.v1
1297violation contains v if {
1298    props := schema_properties("AWS::S3::Bucket")
1299    # Just verify the builtin is callable and returns an array
1300    is_array(props)
1301    v := {"rule_id": "B_SP", "severity": "error", "message": "ok", "resource_id": ""}
1302}
1303"#,
1304            "B_SP",
1305        );
1306        assert_eq!(diags.len(), 1, "schema_properties should be callable and return array");
1307    }
1308
1309    #[test]
1310    fn builtin_schema_required_known_type() {
1311        let diags = eval_builtin_policy(
1312            r#"
1313package builtin_test
1314import rego.v1
1315violation contains v if {
1316    req := schema_required("AWS::Lambda::Function")
1317    is_array(req)
1318    v := {"rule_id": "B_SR", "severity": "error", "message": "ok", "resource_id": ""}
1319}
1320"#,
1321            "B_SR",
1322        );
1323        assert_eq!(diags.len(), 1, "schema_required should be callable and return array");
1324    }
1325
1326    #[test]
1327    fn builtin_conditions_compatible() {
1328        let diags = eval_builtin_policy(
1329            r#"
1330package builtin_test
1331import rego.v1
1332violation contains v if {
1333    conditions_compatible("MyBucket", "MyQueue")
1334    v := {"rule_id": "B_CC", "severity": "error", "message": "ok", "resource_id": ""}
1335}
1336"#,
1337            "B_CC",
1338        );
1339        assert_eq!(diags.len(), 1, "unconditional resources are always compatible");
1340    }
1341
1342    #[test]
1343    fn builtin_resolve_type() {
1344        let diags = eval_builtin_policy(
1345            r#"
1346package builtin_test
1347import rego.v1
1348violation contains v if {
1349    t := resolve_type("MyBucket", "BucketName")
1350    t == "string"
1351    v := {"rule_id": "B_RTYPE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1352}
1353"#,
1354            "B_RTYPE",
1355        );
1356        assert_eq!(diags.len(), 1, "BucketName should resolve to string type");
1357    }
1358
1359    #[test]
1360    fn region_override_available_to_builtins() {
1361        let custom_rego = r#"
1362package region_test
1363import rego.v1
1364violation contains v if {
1365    r := input_region()
1366    r == "eu-west-1"
1367    v := {"rule_id": "B_REGION", "severity": "error", "message": "ok", "resource_id": ""}
1368}
1369"#;
1370        let config = EngineConfig {
1371            custom_rules: vec![ExternalRuleSource { name: "region_test.rego".into(), content: custom_rego.into() }],
1372            guard_rules: vec![],
1373            ..Default::default()
1374        };
1375        let engine = RegoEngine::new(config).unwrap();
1376        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1377        let mut vc = ValidateConfig::default();
1378        vc.pseudo_parameter_overrides.region = Some("eu-west-1".to_string());
1379        let diags = engine.evaluate_rules(&model, &vc).unwrap();
1380        let found = diags.iter().find(|d| d.rule_id == "B_REGION");
1381        assert!(found.is_some(), "input_region() should return eu-west-1");
1382    }
1383
1384    #[test]
1385    fn multiple_custom_packages_all_evaluated() {
1386        let pkg_a = r#"
1387package custom_a
1388import rego.v1
1389violation contains v if {
1390    v := {"rule_id": "PKG_A", "severity": "error", "message": "from A", "resource_id": ""}
1391}
1392"#;
1393        let pkg_b = r#"
1394package custom_b
1395import rego.v1
1396violation contains v if {
1397    v := {"rule_id": "PKG_B", "severity": "error", "message": "from B", "resource_id": ""}
1398}
1399"#;
1400        let config = EngineConfig {
1401            custom_rules: vec![
1402                ExternalRuleSource { name: "a.rego".into(), content: pkg_a.into() },
1403                ExternalRuleSource { name: "b.rego".into(), content: pkg_b.into() },
1404            ],
1405            guard_rules: vec![],
1406            ..Default::default()
1407        };
1408        let engine = RegoEngine::new(config).unwrap();
1409        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1410        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1411        assert!(diags.iter().any(|d| d.rule_id == "PKG_A"), "package A should fire");
1412        assert!(diags.iter().any(|d| d.rule_id == "PKG_B"), "package B should fire");
1413    }
1414
1415    #[test]
1416    fn duplicate_custom_package_deduplicated() {
1417        let source = r#"
1418package custom_dedup
1419import rego.v1
1420violation contains v if {
1421    v := {"rule_id": "DEDUP1", "severity": "error", "message": "first", "resource_id": ""}
1422}
1423"#;
1424        let config = EngineConfig {
1425            custom_rules: vec![
1426                ExternalRuleSource { name: "a.rego".into(), content: source.into() },
1427                ExternalRuleSource { name: "b.rego".into(), content: source.into() },
1428            ],
1429            guard_rules: vec![],
1430            ..Default::default()
1431        };
1432        let engine = RegoEngine::new(config).unwrap();
1433        assert_eq!(
1434            engine.custom_packages.iter().filter(|p| p.contains("custom_dedup")).count(),
1435            1,
1436            "duplicate package should be deduplicated"
1437        );
1438    }
1439
1440    #[test]
1441    fn builtin_follow_ref_returns_target_resource() {
1442        let diags = eval_builtin_policy(
1443            r#"
1444package builtin_test
1445import rego.v1
1446violation contains v if {
1447    target := follow_ref("MyFunc", "Code.S3Bucket")
1448    target == "MyBucket"
1449    v := {"rule_id": "B_FREF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1450}
1451"#,
1452            "B_FREF",
1453        );
1454        assert_eq!(diags.len(), 1, "follow_ref should resolve Ref to MyBucket");
1455    }
1456
1457    #[test]
1458    fn builtin_is_from_parameter_true_for_ref_param() {
1459        let diags = eval_builtin_policy(
1460            r#"
1461package builtin_test
1462import rego.v1
1463violation contains v if {
1464    is_from_parameter("MyBucket", "Tags.0.Value")
1465    v := {"rule_id": "B_IFP", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1466}
1467"#,
1468            "B_IFP",
1469        );
1470        // Tags.0.Value uses Fn::If with a condition based on a parameter, so it's from a parameter
1471        assert!(diags.len() <= 1, "expected at most 1 diagnostic, got {}", diags.len());
1472    }
1473
1474    #[test]
1475    fn builtin_flatten_list_returns_items() {
1476        let diags = eval_builtin_policy(
1477            r#"
1478package builtin_test
1479import rego.v1
1480violation contains v if {
1481    items := flatten_list("MyBucket", "Tags")
1482    count(items) >= 1
1483    v := {"rule_id": "B_FL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1484}
1485"#,
1486            "B_FL",
1487        );
1488        assert_eq!(diags.len(), 1, "flatten_list should return tag items");
1489    }
1490
1491    #[test]
1492    fn builtin_resolve_scenarios_returns_values() {
1493        let diags = eval_builtin_policy(
1494            r#"
1495package builtin_test
1496import rego.v1
1497violation contains v if {
1498    scenarios := resolve_scenarios("MyBucket", "Tags.0.Value")
1499    count(scenarios) >= 1
1500    v := {"rule_id": "B_RS2", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1501}
1502"#,
1503            "B_RS2",
1504        );
1505        assert_eq!(diags.len(), 1, "resolve_scenarios should return at least one scenario");
1506    }
1507
1508    #[test]
1509    fn builtin_is_satisfiable_with_compatible_assumptions() {
1510        let diags = eval_builtin_policy(
1511            r#"
1512package builtin_test
1513import rego.v1
1514violation contains v if {
1515    is_satisfiable({"IsProd": true})
1516    v := {"rule_id": "B_SAT", "severity": "error", "message": "ok", "resource_id": ""}
1517}
1518"#,
1519            "B_SAT",
1520        );
1521        assert_eq!(diags.len(), 1, "IsProd=true should be satisfiable");
1522    }
1523
1524    #[test]
1525    fn builtin_make_diag_produces_diagnostic() {
1526        let diags = eval_builtin_policy(
1527            r#"
1528package builtin_test
1529import rego.v1
1530violation contains v if {
1531    v := make_diag("B_MD", "error", "MyBucket", "test message")
1532}
1533"#,
1534            "B_MD",
1535        );
1536        assert_eq!(diags.len(), 1);
1537        assert_eq!(diags[0].message, "test message");
1538    }
1539
1540    #[test]
1541    fn builtin_estimated_string_length_bounds_returns_both_bounds() {
1542        // `!If [IsProd, "production", "development"]` - the deployment picks one of
1543        // two known values, so the length is bounded but not fixed.
1544        let diags = eval_builtin_policy(
1545            r#"
1546package builtin_test
1547import rego.v1
1548violation contains v if {
1549    bounds := estimated_string_length_bounds("MyBucket", "Tags.0.Value")
1550    bounds.shortest == 10
1551    bounds.longest == 11
1552    v := {"rule_id": "B_ESL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1553}
1554"#,
1555            "B_ESL",
1556        );
1557        assert_eq!(diags.len(), 1, "a value chosen between two known strings must report both bounds");
1558    }
1559
1560    #[test]
1561    fn builtin_estimated_string_length_bounds_is_undefined_for_a_literal() {
1562        // Schema validation checks a literal against the constraint, so this rule
1563        // is not given one to estimate.
1564        let diags = eval_builtin_policy(
1565            r#"
1566package builtin_test
1567import rego.v1
1568violation contains v if {
1569    bounds := estimated_string_length_bounds("MyBucket", "BucketName")
1570    v := {"rule_id": "B_ESL_LITERAL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1571}
1572"#,
1573            "B_ESL_LITERAL",
1574        );
1575        assert!(diags.is_empty(), "a literal string must yield no bounds, got {diags:?}");
1576    }
1577
1578    #[test]
1579    fn regoengine_is_send_and_sync() {
1580        fn assert_send_sync<T: Send + Sync>() {}
1581        assert_send_sync::<RegoEngine>();
1582    }
1583
1584    #[test]
1585    fn evaluate_rules_isolates_model_and_region_across_threads() {
1586        // One engine, many threads, each with a distinct model and region. The
1587        // probes echo the region and the resource names the thread should see, so
1588        // any leakage between threads' evaluation contexts surfaces as a mismatch.
1589        let probe = r#"
1590package concurrency_probe
1591import rego.v1
1592violation contains v if {
1593    r := input_region()
1594    v := {"rule_id": "REGIONPROBE", "severity": "info", "message": r, "resource_id": ""}
1595}
1596violation contains v if {
1597    some name in resources_of_type("AWS::S3::Bucket")
1598    v := {"rule_id": "MODELPROBE", "severity": "info", "message": name, "resource_id": name}
1599}
1600violation contains v if {
1601    some name, _ in input.resources
1602    v := {"rule_id": "INPUTPROBE", "severity": "info", "message": name, "resource_id": name}
1603}
1604"#;
1605        let config = EngineConfig {
1606            custom_rules: vec![ExternalRuleSource { name: "concurrency_probe.rego".into(), content: probe.into() }],
1607            guard_rules: vec![],
1608            ..Default::default()
1609        };
1610        let engine = Arc::new(RegoEngine::new(config).unwrap());
1611
1612        const THREAD_COUNT: usize = 8;
1613        const ITERATIONS_PER_THREAD: usize = 128;
1614        let barrier = Arc::new(Barrier::new(THREAD_COUNT));
1615        let handles: Vec<_> = (0..THREAD_COUNT)
1616            .map(|i| {
1617                let engine = Arc::clone(&engine);
1618                let barrier = Arc::clone(&barrier);
1619                thread::spawn(move || {
1620                    let region = format!("region-{i}");
1621                    let bucket = format!("Bucket{i}");
1622                    let model = make_model_from_yaml(&format!(
1623                        "AWSTemplateFormatVersion: \"2010-09-09\"\nResources:\n  {bucket}:\n    Type: AWS::S3::Bucket\n"
1624                    ));
1625                    let mut config = ValidateConfig::default();
1626                    config.pseudo_parameter_overrides.region = Some(region.clone());
1627                    // Release every thread at once so their evaluations overlap.
1628                    barrier.wait();
1629                    let mut diagnostics = Vec::new();
1630                    for _ in 0..ITERATIONS_PER_THREAD {
1631                        diagnostics = engine.evaluate_rules(&model, &config).unwrap();
1632                        let observed_regions: Vec<&str> = diagnostics
1633                            .iter()
1634                            .filter(|diagnostic| diagnostic.rule_id == "REGIONPROBE")
1635                            .map(|diagnostic| diagnostic.message.as_str())
1636                            .collect();
1637                        assert_eq!(observed_regions, vec![region.as_str()]);
1638                        let observed_resources: Vec<&str> = diagnostics
1639                            .iter()
1640                            .filter(|diagnostic| diagnostic.rule_id == "MODELPROBE")
1641                            .map(|diagnostic| diagnostic.message.as_str())
1642                            .collect();
1643                        assert_eq!(observed_resources, vec![bucket.as_str()]);
1644                        let observed_input_resources: Vec<&str> = diagnostics
1645                            .iter()
1646                            .filter(|diagnostic| diagnostic.rule_id == "INPUTPROBE")
1647                            .map(|diagnostic| diagnostic.message.as_str())
1648                            .collect();
1649                        assert_eq!(observed_input_resources, vec![bucket.as_str()]);
1650                    }
1651                    (region, bucket, diagnostics)
1652                })
1653            })
1654            .collect();
1655
1656        for handle in handles {
1657            let (region, bucket, diags) = handle.join().unwrap();
1658            let regions: Vec<&str> =
1659                diags.iter().filter(|d| d.rule_id == "REGIONPROBE").map(|d| d.message.as_str()).collect();
1660            assert_eq!(regions, vec![region.as_str()], "a thread must observe only its own region");
1661            let resources: Vec<&str> =
1662                diags.iter().filter(|d| d.rule_id == "MODELPROBE").map(|d| d.message.as_str()).collect();
1663            assert_eq!(resources, vec![bucket.as_str()], "a thread must observe only its own model");
1664            let input_resources: Vec<&str> =
1665                diags.iter().filter(|d| d.rule_id == "INPUTPROBE").map(|d| d.message.as_str()).collect();
1666            assert_eq!(input_resources, vec![bucket.as_str()], "a thread must observe only its own Rego input");
1667        }
1668    }
1669
1670    #[test]
1671    fn shared_engine_keeps_thousand_sequential_reports_template_local() {
1672        const CUSTOM_RULE: &str = r#"
1673package sequential_isolation
1674import rego.v1
1675
1676violation contains v if {
1677    pattern := input.template.description
1678    some name, resource in input.resources
1679    regex.match(pattern, resource.properties.Value)
1680    region := input_region()
1681    message := sprintf("%s/%s", [name, region])
1682    v := {
1683        "rule_id": "SEQUENTIALPROBE",
1684        "severity": "info",
1685        "category": "isolation",
1686        "message": message,
1687        "resource_id": name,
1688    }
1689}
1690"#;
1691        let engine = RegoEngine::new(EngineConfig {
1692            custom_rules: vec![ExternalRuleSource {
1693                name: "sequential_isolation.rego".into(),
1694                content: CUSTOM_RULE.into(),
1695            }],
1696            ..Default::default()
1697        })
1698        .unwrap();
1699        let schema_validator = SchemaValidator::default();
1700
1701        for iteration in 0..1_000 {
1702            let (resource_name, region, pattern, value) = if iteration % 2 == 0 {
1703                ("AlphaResource", "alpha-region", "^alpha+$", "alpha")
1704            } else {
1705                ("BetaResource", "beta-region", "^beta+$", "beta")
1706            };
1707            let template = format!(
1708                r#"{{
1709                    "AWSTemplateFormatVersion": "2010-09-09",
1710                    "Description": "{pattern}",
1711                    "Resources": {{
1712                        "{resource_name}": {{
1713                            "Type": "Custom::Probe",
1714                            "Properties": {{"Value": "{value}"}}
1715                        }}
1716                    }}
1717                }}"#
1718            );
1719            let mut config = ValidateConfig {
1720                detail_level: DetailLevel::Detailed,
1721                severity_level: Severity::Debug,
1722                disable_builtin_rules: true,
1723                ..Default::default()
1724            };
1725            config.pseudo_parameter_overrides.region = Some(region.to_string());
1726
1727            let report = validate_bytes(&engine, &schema_validator, template.as_bytes(), config).unwrap();
1728            let probes: Vec<&Diagnostic> =
1729                report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "SEQUENTIALPROBE").collect();
1730            assert_eq!(probes.len(), 1, "iteration {iteration} must emit exactly one probe diagnostic");
1731            let expected_message = format!("{resource_name}/{region}");
1732            assert_eq!(probes[0].message, expected_message, "the current template supplies the diagnostic message");
1733            assert_eq!(
1734                probes[0].rule_description.as_deref(),
1735                Some(expected_message.as_str()),
1736                "the current template supplies its own detailed rule description"
1737            );
1738            assert_eq!(probes[0].category.as_deref(), Some("isolation"));
1739        }
1740    }
1741
1742    #[test]
1743    fn failed_evaluation_does_not_contaminate_the_next_call() {
1744        const CUSTOM_RULE: &str = r#"
1745package error_cleanup
1746import rego.v1
1747
1748violation contains v if {
1749    input.template.description == "error"
1750    ip_overlaps("not-a-cidr", "10.0.0.0/8")
1751    v := {"rule_id": "UNREACHABLE", "severity": "error", "message": "unreachable"}
1752}
1753
1754violation contains v if {
1755    input.template.description == "ok"
1756    some name in resources_of_type("Custom::Probe")
1757    region := input_region()
1758    v := {
1759        "rule_id": "RECOVERYPROBE",
1760        "severity": "info",
1761        "message": sprintf("%s/%s", [name, region]),
1762        "resource_id": name,
1763    }
1764}
1765"#;
1766        let engine = RegoEngine::new(EngineConfig {
1767            custom_rules: vec![ExternalRuleSource { name: "error_cleanup.rego".into(), content: CUSTOM_RULE.into() }],
1768            ..Default::default()
1769        })
1770        .unwrap();
1771        let error_model =
1772            make_model_from_yaml("Description: error\nResources:\n  ErrorResource:\n    Type: Custom::Probe\n");
1773        let valid_model =
1774            make_model_from_yaml("Description: ok\nResources:\n  ValidResource:\n    Type: Custom::Probe\n");
1775        let mut error_config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
1776        error_config.pseudo_parameter_overrides.region = Some("error-region".to_string());
1777        assert!(engine.evaluate_rules(&error_model, &error_config).is_err(), "the first evaluation must fail");
1778
1779        let mut valid_config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
1780        valid_config.pseudo_parameter_overrides.region = Some("valid-region".to_string());
1781        let diagnostics = engine.evaluate_rules(&valid_model, &valid_config).unwrap();
1782        let recovery: Vec<&Diagnostic> =
1783            diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "RECOVERYPROBE").collect();
1784        assert_eq!(recovery.len(), 1, "the next evaluation must recover after the prior error");
1785        assert_eq!(recovery[0].message, "ValidResource/valid-region");
1786    }
1787
1788    const SUPPRESSION_TEMPLATE: &str = r#"
1789AWSTemplateFormatVersion: "2010-09-09"
1790Resources:
1791  MyInstance:
1792    Type: AWS::EC2::Instance
1793    Properties:
1794      ImageId: ami-12345678
1795      InstanceType: !Sub "t3.micro"
1796"#;
1797
1798    fn exclude_ids(ids: &[&str]) -> ValidateConfig {
1799        ValidateConfig {
1800            filters: FilterConfig::new(
1801                RuleFilterConfig::default(),
1802                RuleFilterConfig { ids: ids.iter().map(|s| s.to_string()).collect(), ..Default::default() },
1803            ),
1804            ..Default::default()
1805        }
1806    }
1807
1808    #[test]
1809    fn exclude_id_suppresses_matching_builtin_and_preserves_other_findings() {
1810        let engine = make_engine();
1811        let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
1812
1813        let unfiltered = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1814        assert!(unfiltered.iter().any(|d| d.rule_id == "W9010"), "hardcoded AMI should trigger W9010");
1815        let mut other_findings: Vec<(String, String)> = unfiltered
1816            .iter()
1817            .filter(|d| d.rule_id != "W9010")
1818            .map(|d| (d.rule_id.clone(), d.message.clone()))
1819            .collect();
1820        assert!(!other_findings.is_empty(), "template must also produce a non-W9010 finding worth preserving");
1821        other_findings.sort();
1822
1823        let filtered = engine.evaluate_rules(&model, &exclude_ids(&["W9010"])).unwrap();
1824        assert!(filtered.iter().all(|d| d.rule_id != "W9010"), "the excluded rule must not be emitted");
1825        let mut remaining: Vec<(String, String)> =
1826            filtered.iter().map(|d| (d.rule_id.clone(), d.message.clone())).collect();
1827        remaining.sort();
1828        assert_eq!(remaining, other_findings, "excluding one rule must not alter any other diagnostic");
1829
1830        let unfiltered_again = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1831        assert!(
1832            unfiltered_again.iter().any(|diagnostic| diagnostic.rule_id == "W9010"),
1833            "a later unfiltered call must not inherit the previous call's suppressed-rule set"
1834        );
1835    }
1836
1837    #[test]
1838    fn include_id_globally_suppresses_every_other_builtin() {
1839        let engine = make_engine();
1840        let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
1841
1842        let config = ValidateConfig {
1843            filters: FilterConfig::new(
1844                RuleFilterConfig { ids: vec!["W9010".to_string()], ..Default::default() },
1845                RuleFilterConfig::default(),
1846            ),
1847            ..Default::default()
1848        };
1849        let filtered = engine.evaluate_rules(&model, &config).unwrap();
1850        assert!(filtered.iter().any(|d| d.rule_id == "W9010"), "the included rule must still fire");
1851        assert!(
1852            filtered.iter().all(|d| d.rule_id == "W9010"),
1853            "a non-empty include filter must globally suppress every rule it can never admit"
1854        );
1855    }
1856
1857    #[test]
1858    fn severity_level_prunes_lower_severity_builtins() {
1859        let engine = make_engine();
1860        let model = make_model_from_yaml(SUPPRESSION_TEMPLATE);
1861
1862        let default_run = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1863        assert!(default_run.iter().any(|d| d.rule_id == "W9010"), "W9010 fires at the default Info level");
1864
1865        let config = ValidateConfig { severity_level: Severity::Error, ..Default::default() };
1866        let error_only = engine.evaluate_rules(&model, &config).unwrap();
1867        assert!(
1868            error_only.iter().all(|d| d.severity >= Severity::Error),
1869            "no sub-Error diagnostics should be produced when the level is Error"
1870        );
1871        assert!(
1872            error_only.iter().all(|d| d.rule_id != "W9010"),
1873            "the Warn rule W9010 must be pruned at the Error level"
1874        );
1875    }
1876
1877    #[test]
1878    fn cfn_rule_active_guard_short_circuits_a_suppressed_rule_body() {
1879        // Every built-in clause is shaped like this probe: cfn_rule_active guards
1880        // the body, so a globally suppressed rule stops before its body runs. The
1881        // body here would evaluate regex.match on an invalid pattern if reached;
1882        // the probe diagnostic is the observable signal that the body executed.
1883        let probe = r#"
1884package guard_probe
1885import rego.v1
1886violation contains v if {
1887    cfn_rule_active("W9010")
1888    not regex.match("(unterminated", "anything")
1889    v := {"rule_id": "GUARDPROBE", "severity": "error", "message": "guarded body ran", "resource_id": ""}
1890}
1891"#;
1892        let config = EngineConfig {
1893            custom_rules: vec![ExternalRuleSource { name: "guard_probe.rego".into(), content: probe.into() }],
1894            guard_rules: vec![],
1895            ..Default::default()
1896        };
1897        let engine = RegoEngine::new(config).unwrap();
1898        let model = make_model_from_yaml(
1899            r#"
1900AWSTemplateFormatVersion: "2010-09-09"
1901Resources:
1902  MyBucket:
1903    Type: AWS::S3::Bucket
1904"#,
1905        );
1906
1907        let active = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1908        assert!(active.iter().any(|d| d.rule_id == "GUARDPROBE"), "an active rule's guarded body must run");
1909
1910        let suppressed = engine.evaluate_rules(&model, &exclude_ids(&["W9010"])).unwrap();
1911        assert!(
1912            suppressed.iter().all(|d| d.rule_id != "GUARDPROBE"),
1913            "a suppressed rule's guarded body - including its invalid regex - must not run"
1914        );
1915    }
1916}