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