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 guard_translator::{ensure_translatable, pack_name_from_path, parse_guard};
6use log::{debug, info, warn};
7use rules::{Category, RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, build_rule_metadata_map};
8use schema_validator::{OverlayCatalog, SchemaValidator};
9use std::collections::HashMap;
10use std::str::from_utf8;
11use std::sync::{Arc, LazyLock, Mutex};
12use template_model::SemanticModel;
13use validation_engine::{
14    EngineConfig, ValidateConfig, ValidationEngine, ValidationError, build_rule_list, extract_diagnostics,
15    semantic_model_to_input_json,
16};
17
18static REGORUS_DATA: LazyLock<Vec<(&str, &[u8])>> = LazyLock::new(|| {
19    vec![
20        (KNOWN_RESOURCE_TYPES_PATH, &*embedded::KNOWN_RESOURCE_TYPES_BYTES),
21        (PRIMARY_IDENTIFIERS_PATH, &*embedded::PRIMARY_IDENTIFIERS_BYTES),
22        ("data/iam_action_resource_patterns", &*embedded::IAM_ACTION_RESOURCE_PATTERNS_BYTES),
23        ("data/stateful_resource_types", &*embedded::STATEFUL_RESOURCE_TYPES_BYTES),
24        ("data/aws_rds_dbinstance_dbinstanceclass_enum", &*embedded::AWS_RDS_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
25        ("data/aws_ec2_instance_instancetype_enum", &*embedded::AWS_EC2_INSTANCE_INSTANCETYPE_ENUM_BYTES),
26        (
27            "data/aws_emr_cluster_instancetypeconfig_instancetype_enum",
28            &*embedded::AWS_EMR_CLUSTER_INSTANCETYPECONFIG_INSTANCETYPE_ENUM_BYTES,
29        ),
30        ("data/aws_gamelift_fleet_ec2instancetype_enum", &*embedded::AWS_GAMELIFT_FLEET_EC2INSTANCETYPE_ENUM_BYTES),
31        ("data/aws_appstream_fleet_instancetype_enum", &*embedded::AWS_APPSTREAM_FLEET_INSTANCETYPE_ENUM_BYTES),
32        ("data/aws_dax_cluster_nodetype_enum", &*embedded::AWS_DAX_CLUSTER_NODETYPE_ENUM_BYTES),
33        ("data/aws_docdb_dbinstance_dbinstanceclass_enum", &*embedded::AWS_DOCDB_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES),
34        (
35            "data/aws_elasticache_cachecluster_cachenodetype_enum",
36            &*embedded::AWS_ELASTICACHE_CACHECLUSTER_CACHENODETYPE_ENUM_BYTES,
37        ),
38        (
39            "data/aws_managedblockchain_node_nodeconfiguration_instancetype_enum",
40            &*embedded::AWS_MANAGEDBLOCKCHAIN_NODE_NODECONFIGURATION_INSTANCETYPE_ENUM_BYTES,
41        ),
42        (
43            "data/aws_neptune_dbinstance_dbinstanceclass_enum",
44            &*embedded::AWS_NEPTUNE_DBINSTANCE_DBINSTANCECLASS_ENUM_BYTES,
45        ),
46        (
47            "data/aws_rds_dbcluster_dbclusterinstanceclass_enum",
48            &*embedded::AWS_RDS_DBCLUSTER_DBCLUSTERINSTANCECLASS_ENUM_BYTES,
49        ),
50        ("data/aws_rds_dbinstance_db_instance_class", &*embedded::AWS_RDS_DBINSTANCE_DB_INSTANCE_CLASS_BYTES),
51        ("data/aws_redshift_cluster_nodetype_enum", &*embedded::AWS_REDSHIFT_CLUSTER_NODETYPE_ENUM_BYTES),
52        ("data/aws_amazonmq_broker_instancetype_enum", &*embedded::AWS_AMAZONMQ_BROKER_INSTANCETYPE_ENUM_BYTES),
53        (
54            "data/aws_sagemaker_processing_instancetype_enum",
55            &*embedded::AWS_SAGEMAKER_PROCESSING_INSTANCETYPE_ENUM_BYTES,
56        ),
57        ("data/aws_sagemaker_hosting_instancetype_enum", &*embedded::AWS_SAGEMAKER_HOSTING_INSTANCETYPE_ENUM_BYTES),
58        ("data/aws_sagemaker_transform_instancetype_enum", &*embedded::AWS_SAGEMAKER_TRANSFORM_INSTANCETYPE_ENUM_BYTES),
59        ("data/aws_sagemaker_cluster_instancetype_enum", &*embedded::AWS_SAGEMAKER_CLUSTER_INSTANCETYPE_ENUM_BYTES),
60        (
61            "data/aws_elasticsearch_domain_elasticsearchclusterconfig_instancetype_enum",
62            &*embedded::AWS_ELASTICSEARCH_DOMAIN_ELASTICSEARCHCLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
63        ),
64        (
65            "data/aws_opensearchservice_domain_clusterconfig_instancetype_enum",
66            &*embedded::AWS_OPENSEARCHSERVICE_DOMAIN_CLUSTERCONFIG_INSTANCETYPE_ENUM_BYTES,
67        ),
68        (GETATT_ATTRIBUTES_PATH, &*embedded::GETATT_ATTRIBUTES_BYTES),
69        ("data/codepipeline_action_artifact_counts", &*embedded::CODEPIPELINE_ACTION_ARTIFACT_COUNTS_BYTES),
70        ("data/deprecated_resource_types", &*embedded::DEPRECATED_RESOURCE_TYPES_BYTES),
71        ("data/retention_period_requirements", &*embedded::RETENTION_PERIOD_REQUIREMENTS_BYTES),
72        ("data/sensitive_ports", &*embedded::SENSITIVE_PORTS_BYTES),
73        ("data/secretsmanager_arn_fields", &*embedded::SECRETSMANAGER_ARN_FIELDS_BYTES),
74        ("data/rule_data", &*embedded::RULE_DATA_BYTES),
75        ("data/rule_tables", &*embedded::RULE_TABLES_BYTES),
76    ]
77});
78
79const CORE_PACKAGES: &[(Category, &str)] = &[
80    (Category::Structure, "data.structure.violation"),
81    (Category::Intrinsic, "data.intrinsics.violation"),
82    (Category::Reference, "data.references.violation"),
83    (Category::BestPractice, "data.best_practices.violation"),
84    (Category::Resource, "data.resources.violation"),
85];
86
87pub(crate) type SharedModel = Arc<Mutex<Option<Arc<SemanticModel>>>>;
88pub(crate) type SharedRegion = Arc<Mutex<Option<String>>>;
89
90struct HolderGuard {
91    model: SharedModel,
92    region: SharedRegion,
93}
94
95impl Drop for HolderGuard {
96    fn drop(&mut self) {
97        *self.model.lock().unwrap_or_else(|e| e.into_inner()) = None;
98        *self.region.lock().unwrap_or_else(|e| e.into_inner()) = None;
99    }
100}
101
102/// Pre-allocated capacity for merging all embedded JSON data files into one string.
103const MERGED_DATA_INITIAL_CAPACITY: usize = 8 * 1024 * 1024;
104
105/// The [`REGORUS_DATA`] entry holding the catalog of resource types the rules
106/// treat as existing.
107const KNOWN_RESOURCE_TYPES_PATH: &str = "data/known_resource_types";
108
109/// The [`REGORUS_DATA`] entry holding GetAtt attributes and attribute types.
110const GETATT_ATTRIBUTES_PATH: &str = "data/getatt_attributes";
111
112/// The [`REGORUS_DATA`] entry holding primary identifiers per type.
113const PRIMARY_IDENTIFIERS_PATH: &str = "data/primary_identifiers";
114
115/// Re-serializes the known-resource-type catalog with `extra_types` appended, or
116/// returns `None` when there is nothing to add so the embedded bytes are used
117/// verbatim.
118fn extend_known_resource_types(extra_types: &[String]) -> anyhow::Result<Option<String>> {
119    if extra_types.is_empty() {
120        return Ok(None);
121    }
122    let mut catalog: KnownResourceTypes = serde_json::from_slice(&embedded::KNOWN_RESOURCE_TYPES_BYTES)
123        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded known_resource_types data: {e}"))?;
124    anyhow::ensure!(!catalog.known_resource_types.is_empty(), "Embedded known_resource_types data must not be empty");
125    for type_name in extra_types {
126        if !catalog.known_resource_types.contains(type_name) {
127            catalog.known_resource_types.push(type_name.clone());
128        }
129    }
130    Ok(Some(serde_json::to_string(&catalog)?))
131}
132
133/// Extends the embedded getatt_attributes data with overlay catalog entries.
134/// Returns `None` when there is nothing to add.
135fn extend_getatt_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
136    if catalog.getatt_attributes.is_empty() && catalog.getatt_attribute_types.is_empty() {
137        return Ok(None);
138    }
139    let mut data: serde_json::Value = serde_json::from_slice(&embedded::GETATT_ATTRIBUTES_BYTES)
140        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded getatt_attributes data: {e}"))?;
141
142    // Merge getatt_attributes (sort/dedup after merging)
143    if let Some(attrs_obj) = data.get_mut("getatt_attributes").and_then(|v| v.as_object_mut()) {
144        for (type_name, attrs) in &catalog.getatt_attributes {
145            let entry = attrs_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Array(Vec::new()));
146            if let Some(arr) = entry.as_array_mut() {
147                for attr in attrs {
148                    let val = serde_json::Value::String(attr.clone());
149                    if !arr.contains(&val) {
150                        arr.push(val);
151                    }
152                }
153                arr.sort_by(|a, b| a.as_str().unwrap_or("").cmp(b.as_str().unwrap_or("")));
154                arr.dedup();
155            }
156        }
157    }
158
159    // Merge getatt_attribute_types
160    if let Some(types_obj) = data.get_mut("getatt_attribute_types").and_then(|v| v.as_object_mut()) {
161        for (type_name, attr_types) in &catalog.getatt_attribute_types {
162            let entry =
163                types_obj.entry(type_name.clone()).or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
164            if let Some(obj) = entry.as_object_mut() {
165                for (attr, atype) in attr_types {
166                    obj.insert(attr.clone(), serde_json::Value::String(atype.clone()));
167                }
168            }
169        }
170    }
171
172    Ok(Some(serde_json::to_string(&data)?))
173}
174
175/// Extends the embedded primary_identifiers data with overlay catalog entries.
176/// Returns `None` when there is nothing to add.
177fn extend_primary_identifiers_data(catalog: &OverlayCatalog) -> anyhow::Result<Option<String>> {
178    if catalog.primary_identifiers.is_empty() {
179        return Ok(None);
180    }
181    let mut data: serde_json::Value = serde_json::from_slice(&embedded::PRIMARY_IDENTIFIERS_BYTES)
182        .map_err(|e| anyhow::anyhow!("Failed to parse the embedded primary_identifiers data: {e}"))?;
183
184    if let Some(pids_obj) = data.get_mut("primary_identifiers").and_then(|v| v.as_object_mut()) {
185        for (type_name, pids) in &catalog.primary_identifiers {
186            pids_obj.insert(type_name.clone(), serde_json::json!(pids));
187        }
188    }
189
190    Ok(Some(serde_json::to_string(&data)?))
191}
192
193pub struct RegoEngine {
194    base_rego: regorus::Engine,
195    model_holder: SharedModel,
196    region_holder: SharedRegion,
197    validate_lock: Mutex<()>,
198    /// Built-in rule metadata from the rules registry only.
199    registry_metadata: HashMap<String, RuleMetadataEntry>,
200    /// Metadata for custom user rules and translated guard rules.
201    external_rule_metadata: HashMap<String, RuleMetadataEntry>,
202    /// Custom rego rule metadata discovered from evaluation output.
203    /// Rego rules embed metadata in their output objects, so it can only
204    /// be extracted after evaluation. May be incomplete if not all rules fired.
205    discovered_custom_metadata: Mutex<HashMap<String, RuleMetadataEntry>>,
206    custom_packages: Vec<String>,
207    guard_packages: Vec<String>,
208    init_metric: PhaseMetric,
209}
210
211impl RegoEngine {
212    pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
213        let overlay_catalog =
214            config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
215        Self::new_from_catalog(config, &overlay_catalog)
216    }
217
218    /// Constructs the engine reusing metadata from an already-built
219    /// [`SchemaValidator`](schema_validator::SchemaValidator). The validator's
220    /// overlay catalog is treated as authoritative - the engine does not
221    /// re-resolve overlay schemas.
222    ///
223    /// This entry point is intended for language bindings and the CLI, which
224    /// construct a `SchemaValidator` once and share it with the engine.
225    #[doc(hidden)]
226    pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
227        Self::new_from_catalog(config, validator.overlay_catalog())
228    }
229
230    /// Internal constructor that accepts a pre-built overlay catalog.
231    fn new_from_catalog(config: EngineConfig, overlay_catalog: &OverlayCatalog) -> anyhow::Result<Self> {
232        let start = web_time::Instant::now();
233
234        let mut rego = regorus::Engine::new();
235        rego.set_strict_builtin_errors(false);
236
237        // Single-pass merge avoids per-file JSON parsing overhead.
238        {
239            // Resource types introduced by an overlay schema are legitimate
240            // targets, so the type catalog the rules consult must include them
241            // rather than reporting them as nonexistent.
242            let overlay_types: Vec<String> = overlay_catalog.type_names.clone();
243            let extended_known_types = extend_known_resource_types(&overlay_types)?;
244
245            // Extend getatt_attributes data with overlay entries
246            let extended_getatt = extend_getatt_data(overlay_catalog)?;
247            // Extend primary_identifiers data with overlay entries
248            let extended_primary_ids = extend_primary_identifiers_data(overlay_catalog)?;
249
250            let mut merged = String::with_capacity(MERGED_DATA_INITIAL_CAPACITY);
251            merged.push('{');
252            for (i, (path, json_bytes)) in REGORUS_DATA.iter().enumerate() {
253                let json_str = match (
254                    *path,
255                    extended_known_types.as_deref(),
256                    extended_getatt.as_deref(),
257                    extended_primary_ids.as_deref(),
258                ) {
259                    (KNOWN_RESOURCE_TYPES_PATH, Some(extended), _, _) => extended,
260                    (GETATT_ATTRIBUTES_PATH, _, Some(extended), _) => extended,
261                    (PRIMARY_IDENTIFIERS_PATH, _, _, Some(extended)) => extended,
262                    _ => from_utf8(json_bytes).map_err(|error| {
263                        anyhow::anyhow!("Embedded JSON data '{}' is not valid UTF-8: {}", path, error)
264                    })?,
265                };
266                let inner = json_str
267                    .trim()
268                    .strip_prefix('{')
269                    .and_then(|value| value.strip_suffix('}'))
270                    .ok_or_else(|| anyhow::anyhow!("Embedded JSON data '{}' must be a top-level object", path))?;
271                anyhow::ensure!(!inner.trim().is_empty(), "Embedded JSON data '{}' must not be empty", path);
272                if i > 0 {
273                    merged.push(',');
274                }
275                merged.push_str(inner);
276            }
277            merged.push('}');
278            rego.add_data(regorus::Value::from_json_str(&merged)?)?;
279        }
280
281        for (path, source) in policies::HANDWRITTEN_REGO_POLICIES {
282            rego.add_policy(path.to_string(), source.to_string())?;
283        }
284        debug!(
285            "Loaded {} data files, {} handwritten rules",
286            REGORUS_DATA.len(),
287            policies::HANDWRITTEN_REGO_POLICIES.len()
288        );
289
290        let mut translated_guard_sources = Vec::new();
291        let mut guard_rule_metadata: Vec<(String, Option<String>, String, Severity, RuleOrigin)> = Vec::new();
292        for entry in &config.guard_rules {
293            let guard_file = parse_guard(&entry.content, &entry.name)
294                .map_err(|e| anyhow::anyhow!("Failed to parse guard file '{}': {}", entry.name, e))?;
295            ensure_translatable(&guard_file)
296                .map_err(|e| anyhow::anyhow!("Unsupported guard rule in '{}': {}", entry.name, e))?;
297            let pack = pack_name_from_path(&entry.name);
298            for tr in crate::guard_to_rego::translate_to_rego(&guard_file, &pack, &[]) {
299                guard_rule_metadata.push((
300                    tr.rule_id.clone(),
301                    tr.category.clone(),
302                    tr.description.clone(),
303                    Severity::Error,
304                    RuleOrigin::Guard,
305                ));
306                translated_guard_sources.push((tr.path, tr.source));
307            }
308        }
309
310        for entry in &config.custom_rules {
311            rego.add_policy(entry.name.clone(), entry.content.clone())?;
312        }
313        for (path, source) in &translated_guard_sources {
314            rego.add_policy(path.clone(), source.clone())?;
315        }
316        let mut custom_packages = Vec::new();
317        for entry in &config.custom_rules {
318            for line in entry.content.lines() {
319                let trimmed = line.trim();
320                if let Some(pkg) = trimmed.strip_prefix("package ") {
321                    let eval_path = format!("data.{}.violation", pkg.trim());
322                    if !custom_packages.contains(&eval_path) {
323                        custom_packages.push(eval_path);
324                    }
325                }
326            }
327        }
328        let mut guard_packages = Vec::new();
329        for (_, source) in &translated_guard_sources {
330            for line in source.lines() {
331                let trimmed = line.trim();
332                if let Some(pkg) = trimmed.strip_prefix("package ") {
333                    let eval_path = format!("data.{}.violation", pkg.trim());
334                    if !guard_packages.contains(&eval_path) {
335                        guard_packages.push(eval_path);
336                    }
337                }
338            }
339        }
340
341        let model_holder: SharedModel = Arc::new(Mutex::new(None));
342        let region_holder: SharedRegion = Arc::new(Mutex::new(None));
343        crate::builtins::register_all(&mut rego, model_holder.clone(), region_holder.clone(), overlay_catalog)?;
344
345        let registry_metadata = build_rule_metadata_map();
346        let mut external_rule_metadata: HashMap<String, RuleMetadataEntry> = HashMap::new();
347        for (id, cat, desc, severity, origin) in guard_rule_metadata {
348            external_rule_metadata.entry(id).or_insert(RuleMetadataEntry {
349                category: cat,
350                description: desc,
351                severity,
352                origin,
353            });
354        }
355
356        rego.set_input(regorus::Value::new_object());
357        let _ = rego.eval_rule("data.all_violations.violation".to_string());
358
359        info!(
360            "RegoEngine initialized: {} handwritten rules, {} data files, {} registry + {} external metadata entries",
361            policies::HANDWRITTEN_REGO_POLICIES.len(),
362            REGORUS_DATA.len(),
363            registry_metadata.len(),
364            external_rule_metadata.len()
365        );
366        let init_metric = phase_metric(start);
367        Ok(RegoEngine {
368            base_rego: rego,
369            model_holder,
370            region_holder,
371            validate_lock: Mutex::new(()),
372            registry_metadata,
373            external_rule_metadata,
374            discovered_custom_metadata: Mutex::new(HashMap::new()),
375            custom_packages,
376            guard_packages,
377            init_metric,
378        })
379    }
380
381    /// Evaluates a single Rego package and appends its diagnostics to `out`.
382    ///
383    /// Any evaluation or serialization failure is returned as a structured
384    /// [`ValidationError`] - an exception the caller can handle - and is never
385    /// converted into a diagnostic. A rule that fails to run must surface as an
386    /// error, not masquerade as a finding.
387    fn eval_package_into(
388        &self,
389        rego: &mut regorus::Engine,
390        package: &str,
391        source_label: &str,
392        model: &SemanticModel,
393        origin: Option<&RuleOrigin>,
394        out: &mut Vec<Diagnostic>,
395    ) -> Result<(), ValidationError> {
396        let value = rego.eval_rule(package.to_string()).map_err(|e| {
397            ValidationError::Engine(format!("{source_label} rule package '{package}' failed to evaluate: {e}"))
398        })?;
399        let json_str = value.to_json_str().map_err(|e| {
400            ValidationError::Engine(format!(
401                "{source_label} rule package '{package}' produced a result that could not be \
402                 serialized to JSON: {e}"
403            ))
404        })?;
405        extract_diagnostics(&json_str, model, out, origin).map_err(ValidationError::from)
406    }
407}
408
409impl ValidationEngine for RegoEngine {
410    fn engine_name(&self) -> &str {
411        "rego"
412    }
413
414    fn evaluate_rules(
415        &self,
416        model: &Arc<SemanticModel>,
417        config: &ValidateConfig,
418    ) -> Result<Vec<Diagnostic>, ValidationError> {
419        let _validate_guard = self.validate_lock.lock().unwrap_or_else(|e| e.into_inner());
420
421        *self.model_holder.lock().unwrap_or_else(|e| e.into_inner()) = Some(model.clone());
422        *self.region_holder.lock().unwrap_or_else(|e| e.into_inner()) =
423            config.pseudo_parameter_overrides.region.clone();
424
425        let _cleanup = HolderGuard { model: self.model_holder.clone(), region: self.region_holder.clone() };
426
427        let mut rego = self.base_rego.clone();
428
429        let input_json = semantic_model_to_input_json(model)?;
430        let input_value = crate::builtins::serde_json_to_rego_value(&input_json);
431        rego.set_input(input_value);
432
433        let mut diagnostics = Vec::new();
434
435        if !config.disable_builtin_rules {
436            let excluded_cats = config.filters.excluded_categories();
437
438            let needed_core: Vec<&str> = CORE_PACKAGES
439                .iter()
440                .filter(|(cat, _)| !excluded_cats.contains(cat.as_str()))
441                .map(|(_, pkg)| *pkg)
442                .collect();
443
444            if needed_core.len() == CORE_PACKAGES.len() {
445                match rego.eval_rule("data.all_violations.violation".to_string()) {
446                    Ok(val) => {
447                        let json_str = val.to_json_str().map_err(|e| {
448                            ValidationError::Engine(format!(
449                                "Aggregated rule evaluation produced a result that could not be \
450                             serialized to JSON: {e}"
451                            ))
452                        })?;
453                        extract_diagnostics(&json_str, model, &mut diagnostics, None).map_err(ValidationError::from)?;
454                    }
455                    Err(e) => {
456                        warn!("Aggregated eval failed ({}), falling back to individual packages", e);
457                        for pkg in &needed_core {
458                            self.eval_package_into(&mut rego, pkg, "Core", model, None, &mut diagnostics)?;
459                        }
460                    }
461                }
462            } else {
463                debug!("Skipping excluded categories: {:?}", excluded_cats);
464                for pkg in &needed_core {
465                    self.eval_package_into(&mut rego, pkg, "Core", model, None, &mut diagnostics)?;
466                }
467            }
468        }
469
470        for pkg in &self.custom_packages {
471            self.eval_package_into(&mut rego, pkg, "Custom", model, Some(&RuleOrigin::Custom), &mut diagnostics)?;
472        }
473
474        for pkg in &self.guard_packages {
475            self.eval_package_into(&mut rego, pkg, "Guard", model, Some(&RuleOrigin::Guard), &mut diagnostics)?;
476        }
477
478        if !self.custom_packages.is_empty() {
479            let mut discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
480            for d in &diagnostics {
481                if d.source == RuleOrigin::Custom {
482                    discovered.entry(d.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
483                        category: d.category.clone(),
484                        description: d.message.clone(),
485                        severity: d.severity,
486                        origin: RuleOrigin::Custom,
487                    });
488                }
489            }
490        }
491
492        Ok(diagnostics)
493    }
494
495    fn list_rules(&self) -> Vec<RuleInfo> {
496        let mut merged = self.external_rule_metadata.clone();
497        if !self.custom_packages.is_empty() {
498            let discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
499            merged.extend(discovered.iter().map(|(k, v)| (k.clone(), v.clone())));
500        }
501        build_rule_list(&self.registry_metadata, &merged)
502    }
503
504    fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
505        &self.registry_metadata
506    }
507
508    /// Returns guard metadata merged with any custom Rego rule metadata
509    /// discovered from prior evaluations.
510    fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
511        let mut merged = self.external_rule_metadata.clone();
512        if !self.custom_packages.is_empty() {
513            let discovered = self.discovered_custom_metadata.lock().unwrap_or_else(|e| e.into_inner());
514            merged.extend(discovered.iter().map(|(k, v)| (k.clone(), v.clone())));
515        }
516        merged
517    }
518
519    fn init_metric(&self) -> &PhaseMetric {
520        &self.init_metric
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use rules::{FilterConfig, RuleFilterConfig};
528    use template_model::SemanticModel;
529    use validation_engine::{EngineConfig, ExternalRuleSource, ValidateConfig, ValidationEngine};
530
531    fn make_engine() -> RegoEngine {
532        RegoEngine::new(EngineConfig::default()).unwrap()
533    }
534
535    fn make_model_from_yaml(yaml: &str) -> Arc<SemanticModel> {
536        let model = SemanticModel::from_bytes(yaml.as_bytes()).unwrap();
537        Arc::new(model)
538    }
539
540    #[test]
541    fn new_default_config_succeeds() {
542        let engine = make_engine();
543        assert_eq!(engine.engine_name(), "rego");
544    }
545
546    #[test]
547    fn list_rules_returns_nonempty() {
548        let engine = make_engine();
549        let rules = engine.list_rules();
550        assert!(!rules.is_empty(), "should have built-in rules");
551    }
552
553    #[test]
554    fn rule_metadata_contains_entries() {
555        let engine = make_engine();
556        assert!(!engine.rule_metadata().is_empty());
557    }
558
559    #[test]
560    fn init_metric_has_positive_duration() {
561        let engine = make_engine();
562        let metric = engine.init_metric();
563        assert!(metric.duration_ms > 0.0, "init duration should be > 0, got {}", metric.duration_ms);
564    }
565
566    #[test]
567    fn evaluate_minimal_template_no_errors() {
568        let engine = make_engine();
569        let model = make_model_from_yaml(
570            r#"
571AWSTemplateFormatVersion: "2010-09-09"
572Resources:
573  MyBucket:
574    Type: AWS::S3::Bucket
575"#,
576        );
577        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
578        assert!(
579            diags.iter().all(|d| d.severity != Severity::Fatal),
580            "rego engine should not produce Fatal diagnostics"
581        );
582    }
583
584    #[test]
585    fn evaluate_empty_resources_does_not_panic() {
586        let engine = make_engine();
587        let model = make_model_from_yaml(
588            r#"
589AWSTemplateFormatVersion: "2010-09-09"
590Resources: {}
591"#,
592        );
593        // CloudFormation rejects templates with empty Resources, so the empty-resources diagnostic is expected.
594        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
595        assert!(
596            diags.iter().any(|d| d.rule_id == "F0001"),
597            "expected F0001 Fatal for empty Resources section, got: {:?}",
598            diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>(),
599        );
600    }
601
602    #[test]
603    fn evaluate_with_excluded_categories() {
604        let engine = make_engine();
605        let model = make_model_from_yaml(
606            r#"
607AWSTemplateFormatVersion: "2010-09-09"
608Resources:
609  MyBucket:
610    Type: AWS::S3::Bucket
611"#,
612        );
613        let config = ValidateConfig {
614            filters: FilterConfig::new(
615                RuleFilterConfig::default(),
616                RuleFilterConfig { categories: vec!["best_practices".to_string()], ..Default::default() },
617            ),
618            ..Default::default()
619        };
620        let diags_filtered = engine.evaluate_rules(&model, &config).unwrap();
621
622        let diags_all = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
623
624        assert!(
625            diags_filtered.len() <= diags_all.len(),
626            "filtered count {} should be <= unfiltered count {}",
627            diags_filtered.len(),
628            diags_all.len()
629        );
630    }
631
632    #[test]
633    fn custom_rego_rule_produces_diagnostics() {
634        let custom_rego = r#"
635package custom_test
636import rego.v1
637
638violation contains v if {
639    some name, res in input.resources
640    res.resourceType == "AWS::S3::Bucket"
641    v := {"rule_id": "CUSTOM001", "severity": "error", "message": "Custom rule fired", "resource_id": name}
642}
643"#;
644        let config = EngineConfig {
645            custom_rules: vec![ExternalRuleSource { name: "custom_test.rego".into(), content: custom_rego.into() }],
646            guard_rules: vec![],
647            ..Default::default()
648        };
649        let engine = RegoEngine::new(config).unwrap();
650        let model = make_model_from_yaml(
651            r#"
652AWSTemplateFormatVersion: "2010-09-09"
653Resources:
654  MyBucket:
655    Type: AWS::S3::Bucket
656"#,
657        );
658        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
659        let custom = diags.iter().find(|d| d.rule_id == "CUSTOM001");
660        assert!(custom.is_some(), "custom rule should fire");
661    }
662
663    #[test]
664    fn guard_rule_produces_diagnostics() {
665        let guard_source = r#"
666rule check_bucket_name {
667    AWS::S3::Bucket {
668        Properties.BucketName EXISTS
669        <<BucketName must be specified>>
670    }
671}
672"#;
673        let config = EngineConfig {
674            custom_rules: vec![],
675            guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
676            ..Default::default()
677        };
678        let engine = RegoEngine::new(config).unwrap();
679        let model = make_model_from_yaml(
680            r#"
681AWSTemplateFormatVersion: "2010-09-09"
682Resources:
683  MyBucket:
684    Type: AWS::S3::Bucket
685"#,
686        );
687        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
688        let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name");
689        assert!(guard_diag.is_some(), "guard rule should fire when BucketName is missing");
690    }
691
692    #[test]
693    fn guard_rule_exists_check_ignores_properties_prefix() {
694        // BUG: Guard DSL `Properties.BucketName EXISTS` translates to
695        // `has_property(name, "Properties.BucketName")` but has_property looks up
696        // `resource.properties["Properties.BucketName"]` - the actual key is just
697        // "BucketName", so the check always fails and the violation always fires.
698        let guard_source = r#"
699rule check_bucket_name {
700    AWS::S3::Bucket {
701        Properties.BucketName EXISTS
702        <<BucketName must be specified>>
703    }
704}
705"#;
706        let config = EngineConfig {
707            custom_rules: vec![],
708            guard_rules: vec![ExternalRuleSource { name: "test.guard".into(), content: guard_source.into() }],
709            ..Default::default()
710        };
711        let engine = RegoEngine::new(config).unwrap();
712        let model = make_model_from_yaml(
713            r#"
714AWSTemplateFormatVersion: "2010-09-09"
715Resources:
716  MyBucket:
717    Type: AWS::S3::Bucket
718    Properties:
719      BucketName: my-bucket
720"#,
721        );
722        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
723        let guard_diag = diags.iter().find(|d| d.rule_id == "check_bucket_name");
724        assert!(
725            guard_diag.is_some(),
726            "guard rule fires due to Properties. prefix mismatch in has_property (known bug)"
727        );
728    }
729
730    #[test]
731    fn evaluate_rules_is_serialized() {
732        let engine = make_engine();
733        let model = make_model_from_yaml(
734            r#"
735AWSTemplateFormatVersion: "2010-09-09"
736Resources:
737  A:
738    Type: AWS::S3::Bucket
739"#,
740        );
741        // Call twice to verify the mutex + HolderGuard cleanup works without deadlock.
742        let _ = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
743        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
744        assert!(
745            diags.iter().all(|d| d.severity != Severity::Fatal),
746            "rego engine should not produce Fatal diagnostics"
747        );
748    }
749
750    #[test]
751    fn holder_guard_clears_model_on_drop() {
752        let holder: SharedModel = Arc::new(Mutex::new(Some(Arc::new(
753            SemanticModel::from_bytes(b"AWSTemplateFormatVersion: '2010-09-09'\nResources: {}").unwrap(),
754        ))));
755        let region: SharedRegion = Arc::new(Mutex::new(Some("us-east-1".to_string())));
756        {
757            let _guard = HolderGuard { model: holder.clone(), region: region.clone() };
758            assert!(holder.lock().unwrap().is_some(), "holder should be Some while guard is alive");
759        }
760        assert!(holder.lock().unwrap().is_none(), "holder should be None after guard dropped");
761        assert!(region.lock().unwrap().is_none(), "region should be None after guard dropped");
762    }
763
764    #[test]
765    fn build_rule_metadata_map_returns_entries() {
766        let index = build_rule_metadata_map();
767        assert!(!index.is_empty());
768    }
769
770    const BUILTIN_TEST_TEMPLATE: &str = r#"
771AWSTemplateFormatVersion: "2010-09-09"
772Parameters:
773  Env:
774    Type: String
775    AllowedValues:
776      - prod
777      - dev
778  Port:
779    Type: Number
780Conditions:
781  IsProd:
782    Fn::Equals:
783      - !Ref Env
784      - prod
785Mappings:
786  RegionMap:
787    us-east-1:
788      AMI: ami-12345678
789    us-west-2:
790      AMI: ami-abcdefgh
791Resources:
792  MyBucket:
793    Type: AWS::S3::Bucket
794    Properties:
795      BucketName: my-bucket
796      Tags:
797        - Key: Env
798          Value: !If [IsProd, "production", "development"]
799  MyFunc:
800    Type: AWS::Lambda::Function
801    DependsOn: MyBucket
802    Condition: IsProd
803    Properties:
804      FunctionName: my-func
805      Runtime: python3.12
806      Handler: index.handler
807      Code:
808        S3Bucket: !Ref MyBucket
809        S3Key: code.zip
810  MyQueue:
811    Type: AWS::SQS::Queue
812    Properties:
813      QueueName: my-queue
814Transform: AWS::Serverless-2016-10-31
815"#;
816
817    fn eval_builtin_policy(rego_source: &str, rule_id: &str) -> Vec<Diagnostic> {
818        let config = EngineConfig {
819            custom_rules: vec![ExternalRuleSource { name: "builtin_test.rego".into(), content: rego_source.into() }],
820            guard_rules: vec![],
821            ..Default::default()
822        };
823        let engine = RegoEngine::new(config).unwrap();
824        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
825        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
826        diags.into_iter().filter(|d| d.rule_id == rule_id).collect()
827    }
828
829    #[test]
830    fn builtin_resolve_returns_property_value() {
831        let diags = eval_builtin_policy(
832            r#"
833package builtin_test
834import rego.v1
835violation contains v if {
836    val := resolve("MyBucket", "BucketName")
837    val == "my-bucket"
838    v := {"rule_id": "B_RESOLVE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
839}
840"#,
841            "B_RESOLVE",
842        );
843        assert_eq!(diags.len(), 1, "resolve should return 'my-bucket'");
844    }
845
846    #[test]
847    fn builtin_resolve_all_returns_conditional_values() {
848        let diags = eval_builtin_policy(
849            r#"
850package builtin_test
851import rego.v1
852violation contains v if {
853    vals := resolve_all("MyBucket", "Tags.0.Value")
854    count(vals) >= 1
855    v := {"rule_id": "B_RESOLVE_ALL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
856}
857"#,
858            "B_RESOLVE_ALL",
859        );
860        assert_eq!(diags.len(), 1, "resolve_all should return at least one value");
861    }
862
863    #[test]
864    fn builtin_is_dynamic_on_conditional_property() {
865        let diags = eval_builtin_policy(
866            r#"
867package builtin_test
868import rego.v1
869violation contains v if {
870    is_dynamic("MyFunc", "Code.S3Bucket")
871    v := {"rule_id": "B_IS_DYN", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
872}
873"#,
874            "B_IS_DYN",
875        );
876        assert_eq!(diags.len(), 1, "Code.S3Bucket is a Ref → dynamic");
877    }
878
879    #[test]
880    fn builtin_is_dynamic_false_for_static() {
881        let diags = eval_builtin_policy(
882            r#"
883package builtin_test
884import rego.v1
885violation contains v if {
886    not is_dynamic("MyBucket", "BucketName")
887    v := {"rule_id": "B_NOT_DYN", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
888}
889"#,
890            "B_NOT_DYN",
891        );
892        assert_eq!(diags.len(), 1, "BucketName is static");
893    }
894
895    #[test]
896    fn builtin_resources_of_type() {
897        let diags = eval_builtin_policy(
898            r#"
899package builtin_test
900import rego.v1
901violation contains v if {
902    ids := resources_of_type("AWS::S3::Bucket")
903    count(ids) == 1
904    v := {"rule_id": "B_ROT", "severity": "error", "message": "ok", "resource_id": ""}
905}
906"#,
907            "B_ROT",
908        );
909        assert_eq!(diags.len(), 1, "should find 1 S3 bucket");
910    }
911
912    #[test]
913    fn builtin_has_property_true() {
914        let diags = eval_builtin_policy(
915            r#"
916package builtin_test
917import rego.v1
918violation contains v if {
919    has_property("MyBucket", "BucketName")
920    v := {"rule_id": "B_HAS_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
921}
922"#,
923            "B_HAS_P",
924        );
925        assert_eq!(diags.len(), 1, "MyBucket has BucketName");
926    }
927
928    #[test]
929    fn builtin_has_property_false() {
930        let diags = eval_builtin_policy(
931            r#"
932package builtin_test
933import rego.v1
934violation contains v if {
935    not has_property("MyBucket", "NonExistent")
936    v := {"rule_id": "B_NO_P", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
937}
938"#,
939            "B_NO_P",
940        );
941        assert_eq!(diags.len(), 1, "MyBucket does not have NonExistent");
942    }
943
944    #[test]
945    fn builtin_has_transform() {
946        let diags = eval_builtin_policy(
947            r#"
948package builtin_test
949import rego.v1
950violation contains v if {
951    has_transform("AWS::Serverless-2016-10-31")
952    v := {"rule_id": "B_HAS_T", "severity": "error", "message": "ok", "resource_id": ""}
953}
954"#,
955            "B_HAS_T",
956        );
957        assert_eq!(diags.len(), 1, "template has Serverless transform");
958    }
959
960    #[test]
961    fn builtin_param_allowed_values() {
962        let diags = eval_builtin_policy(
963            r#"
964package builtin_test
965import rego.v1
966violation contains v if {
967    vals := param_allowed_values("Env")
968    count(vals) == 2
969    v := {"rule_id": "B_PAV", "severity": "error", "message": "ok", "resource_id": ""}
970}
971"#,
972            "B_PAV",
973        );
974        assert_eq!(diags.len(), 1, "Env has 2 allowed values");
975    }
976
977    #[test]
978    fn builtin_param_type() {
979        let diags = eval_builtin_policy(
980            r#"
981package builtin_test
982import rego.v1
983violation contains v if {
984    t := param_type("Port")
985    t == "Number"
986    v := {"rule_id": "B_PT", "severity": "error", "message": "ok", "resource_id": ""}
987}
988"#,
989            "B_PT",
990        );
991        assert_eq!(diags.len(), 1, "Port param type is Number");
992    }
993
994    #[test]
995    fn builtin_mapping_value() {
996        let diags = eval_builtin_policy(
997            r#"
998package builtin_test
999import rego.v1
1000violation contains v if {
1001    val := mapping_value("RegionMap", "us-east-1", "AMI")
1002    val == "ami-12345678"
1003    v := {"rule_id": "B_MAP", "severity": "error", "message": "ok", "resource_id": ""}
1004}
1005"#,
1006            "B_MAP",
1007        );
1008        assert_eq!(diags.len(), 1, "mapping lookup should return ami-12345678");
1009    }
1010
1011    #[test]
1012    fn builtin_resource_condition() {
1013        let diags = eval_builtin_policy(
1014            r#"
1015package builtin_test
1016import rego.v1
1017violation contains v if {
1018    c := resource_condition("MyFunc")
1019    c == "IsProd"
1020    v := {"rule_id": "B_RC", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1021}
1022"#,
1023            "B_RC",
1024        );
1025        assert_eq!(diags.len(), 1, "MyFunc has condition IsProd");
1026    }
1027
1028    #[test]
1029    fn builtin_resource_condition_null_when_none() {
1030        let diags = eval_builtin_policy(
1031            r#"
1032package builtin_test
1033import rego.v1
1034violation contains v if {
1035    c := resource_condition("MyBucket")
1036    is_null(c)
1037    v := {"rule_id": "B_RC_NULL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1038}
1039"#,
1040            "B_RC_NULL",
1041        );
1042        assert_eq!(diags.len(), 1, "MyBucket has no condition");
1043    }
1044
1045    #[test]
1046    fn builtin_get_resource() {
1047        let diags = eval_builtin_policy(
1048            r#"
1049package builtin_test
1050import rego.v1
1051violation contains v if {
1052    res := get_resource("MyBucket")
1053    res.resourceType == "AWS::S3::Bucket"
1054    v := {"rule_id": "B_GR", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1055}
1056"#,
1057            "B_GR",
1058        );
1059        assert_eq!(diags.len(), 1, "get_resource should return bucket data");
1060    }
1061
1062    #[test]
1063    fn builtin_depends_on() {
1064        let diags = eval_builtin_policy(
1065            r#"
1066package builtin_test
1067import rego.v1
1068violation contains v if {
1069    depends_on("MyFunc", "MyBucket")
1070    v := {"rule_id": "B_DEP", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1071}
1072"#,
1073            "B_DEP",
1074        );
1075        assert_eq!(diags.len(), 1, "MyFunc depends on MyBucket");
1076    }
1077
1078    #[test]
1079    fn builtin_ref_targets() {
1080        let diags = eval_builtin_policy(
1081            r#"
1082package builtin_test
1083import rego.v1
1084violation contains v if {
1085    targets := ref_targets("MyFunc")
1086    count(targets) > 0
1087    v := {"rule_id": "B_RT", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1088}
1089"#,
1090            "B_RT",
1091        );
1092        assert_eq!(diags.len(), 1, "MyFunc should have ref targets (MyBucket)");
1093    }
1094
1095    #[test]
1096    fn builtin_ref_sources() {
1097        let diags = eval_builtin_policy(
1098            r#"
1099package builtin_test
1100import rego.v1
1101violation contains v if {
1102    sources := ref_sources("MyBucket")
1103    count(sources) > 0
1104    v := {"rule_id": "B_RS", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1105}
1106"#,
1107            "B_RS",
1108        );
1109        assert_eq!(diags.len(), 1, "MyBucket should have ref sources (MyFunc)");
1110    }
1111
1112    #[test]
1113    fn builtin_edges_from() {
1114        let diags = eval_builtin_policy(
1115            r#"
1116package builtin_test
1117import rego.v1
1118violation contains v if {
1119    edges := edges_from("MyFunc")
1120    count(edges) > 0
1121    v := {"rule_id": "B_EF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1122}
1123"#,
1124            "B_EF",
1125        );
1126        assert_eq!(diags.len(), 1, "MyFunc should have outgoing edges");
1127    }
1128
1129    #[test]
1130    fn builtin_edges_to() {
1131        let diags = eval_builtin_policy(
1132            r#"
1133package builtin_test
1134import rego.v1
1135violation contains v if {
1136    edges := edges_to("MyBucket")
1137    count(edges) > 0
1138    v := {"rule_id": "B_ET", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1139}
1140"#,
1141            "B_ET",
1142        );
1143        assert_eq!(diags.len(), 1, "MyBucket should have incoming edges");
1144    }
1145
1146    #[test]
1147    fn builtin_make_diag_at() {
1148        let diags = eval_builtin_policy(
1149            r#"
1150package builtin_test
1151import rego.v1
1152violation contains v if {
1153    v := make_diag_at("B_MDA", "error", "MyBucket", "BucketName", "test diag at")
1154}
1155"#,
1156            "B_MDA",
1157        );
1158        assert_eq!(diags.len(), 1);
1159        assert_eq!(diags[0].message, "test diag at");
1160    }
1161
1162    #[test]
1163    fn builtin_make_diag_full() {
1164        let diags = eval_builtin_policy(
1165            r#"
1166package builtin_test
1167import rego.v1
1168violation contains v if {
1169    v := make_diag_full("B_MDF", "error", "MyBucket", "BucketName", "full diag", "fix it", "https://example.com")
1170}
1171"#,
1172            "B_MDF",
1173        );
1174        assert_eq!(diags.len(), 1);
1175        assert_eq!(diags[0].message, "full diag");
1176    }
1177
1178    #[test]
1179    fn builtin_schema_properties_known_type() {
1180        let diags = eval_builtin_policy(
1181            r#"
1182package builtin_test
1183import rego.v1
1184violation contains v if {
1185    props := schema_properties("AWS::S3::Bucket")
1186    # Just verify the builtin is callable and returns an array
1187    is_array(props)
1188    v := {"rule_id": "B_SP", "severity": "error", "message": "ok", "resource_id": ""}
1189}
1190"#,
1191            "B_SP",
1192        );
1193        assert_eq!(diags.len(), 1, "schema_properties should be callable and return array");
1194    }
1195
1196    #[test]
1197    fn builtin_schema_required_known_type() {
1198        let diags = eval_builtin_policy(
1199            r#"
1200package builtin_test
1201import rego.v1
1202violation contains v if {
1203    req := schema_required("AWS::Lambda::Function")
1204    is_array(req)
1205    v := {"rule_id": "B_SR", "severity": "error", "message": "ok", "resource_id": ""}
1206}
1207"#,
1208            "B_SR",
1209        );
1210        assert_eq!(diags.len(), 1, "schema_required should be callable and return array");
1211    }
1212
1213    #[test]
1214    fn builtin_conditions_compatible() {
1215        let diags = eval_builtin_policy(
1216            r#"
1217package builtin_test
1218import rego.v1
1219violation contains v if {
1220    conditions_compatible("MyBucket", "MyQueue")
1221    v := {"rule_id": "B_CC", "severity": "error", "message": "ok", "resource_id": ""}
1222}
1223"#,
1224            "B_CC",
1225        );
1226        assert_eq!(diags.len(), 1, "unconditional resources are always compatible");
1227    }
1228
1229    #[test]
1230    fn builtin_resolve_type() {
1231        let diags = eval_builtin_policy(
1232            r#"
1233package builtin_test
1234import rego.v1
1235violation contains v if {
1236    t := resolve_type("MyBucket", "BucketName")
1237    t == "string"
1238    v := {"rule_id": "B_RTYPE", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1239}
1240"#,
1241            "B_RTYPE",
1242        );
1243        assert_eq!(diags.len(), 1, "BucketName should resolve to string type");
1244    }
1245
1246    #[test]
1247    fn region_override_available_to_builtins() {
1248        let custom_rego = r#"
1249package region_test
1250import rego.v1
1251violation contains v if {
1252    r := input_region()
1253    r == "eu-west-1"
1254    v := {"rule_id": "B_REGION", "severity": "error", "message": "ok", "resource_id": ""}
1255}
1256"#;
1257        let config = EngineConfig {
1258            custom_rules: vec![ExternalRuleSource { name: "region_test.rego".into(), content: custom_rego.into() }],
1259            guard_rules: vec![],
1260            ..Default::default()
1261        };
1262        let engine = RegoEngine::new(config).unwrap();
1263        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1264        let mut vc = ValidateConfig::default();
1265        vc.pseudo_parameter_overrides.region = Some("eu-west-1".to_string());
1266        let diags = engine.evaluate_rules(&model, &vc).unwrap();
1267        let found = diags.iter().find(|d| d.rule_id == "B_REGION");
1268        assert!(found.is_some(), "input_region() should return eu-west-1");
1269    }
1270
1271    #[test]
1272    fn multiple_custom_packages_all_evaluated() {
1273        let pkg_a = r#"
1274package custom_a
1275import rego.v1
1276violation contains v if {
1277    v := {"rule_id": "PKG_A", "severity": "error", "message": "from A", "resource_id": ""}
1278}
1279"#;
1280        let pkg_b = r#"
1281package custom_b
1282import rego.v1
1283violation contains v if {
1284    v := {"rule_id": "PKG_B", "severity": "error", "message": "from B", "resource_id": ""}
1285}
1286"#;
1287        let config = EngineConfig {
1288            custom_rules: vec![
1289                ExternalRuleSource { name: "a.rego".into(), content: pkg_a.into() },
1290                ExternalRuleSource { name: "b.rego".into(), content: pkg_b.into() },
1291            ],
1292            guard_rules: vec![],
1293            ..Default::default()
1294        };
1295        let engine = RegoEngine::new(config).unwrap();
1296        let model = make_model_from_yaml(BUILTIN_TEST_TEMPLATE);
1297        let diags = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
1298        assert!(diags.iter().any(|d| d.rule_id == "PKG_A"), "package A should fire");
1299        assert!(diags.iter().any(|d| d.rule_id == "PKG_B"), "package B should fire");
1300    }
1301
1302    #[test]
1303    fn duplicate_custom_package_deduplicated() {
1304        let source = r#"
1305package custom_dedup
1306import rego.v1
1307violation contains v if {
1308    v := {"rule_id": "DEDUP1", "severity": "error", "message": "first", "resource_id": ""}
1309}
1310"#;
1311        let config = EngineConfig {
1312            custom_rules: vec![
1313                ExternalRuleSource { name: "a.rego".into(), content: source.into() },
1314                ExternalRuleSource { name: "b.rego".into(), content: source.into() },
1315            ],
1316            guard_rules: vec![],
1317            ..Default::default()
1318        };
1319        let engine = RegoEngine::new(config).unwrap();
1320        assert_eq!(
1321            engine.custom_packages.iter().filter(|p| p.contains("custom_dedup")).count(),
1322            1,
1323            "duplicate package should be deduplicated"
1324        );
1325    }
1326
1327    #[test]
1328    fn builtin_follow_ref_returns_target_resource() {
1329        let diags = eval_builtin_policy(
1330            r#"
1331package builtin_test
1332import rego.v1
1333violation contains v if {
1334    target := follow_ref("MyFunc", "Code.S3Bucket")
1335    target == "MyBucket"
1336    v := {"rule_id": "B_FREF", "severity": "error", "message": "ok", "resource_id": "MyFunc"}
1337}
1338"#,
1339            "B_FREF",
1340        );
1341        assert_eq!(diags.len(), 1, "follow_ref should resolve Ref to MyBucket");
1342    }
1343
1344    #[test]
1345    fn builtin_is_from_parameter_true_for_ref_param() {
1346        let diags = eval_builtin_policy(
1347            r#"
1348package builtin_test
1349import rego.v1
1350violation contains v if {
1351    is_from_parameter("MyBucket", "Tags.0.Value")
1352    v := {"rule_id": "B_IFP", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1353}
1354"#,
1355            "B_IFP",
1356        );
1357        // Tags.0.Value uses Fn::If with a condition based on a parameter, so it's from a parameter
1358        assert!(diags.len() <= 1, "expected at most 1 diagnostic, got {}", diags.len());
1359    }
1360
1361    #[test]
1362    fn builtin_flatten_list_returns_items() {
1363        let diags = eval_builtin_policy(
1364            r#"
1365package builtin_test
1366import rego.v1
1367violation contains v if {
1368    items := flatten_list("MyBucket", "Tags")
1369    count(items) >= 1
1370    v := {"rule_id": "B_FL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1371}
1372"#,
1373            "B_FL",
1374        );
1375        assert_eq!(diags.len(), 1, "flatten_list should return tag items");
1376    }
1377
1378    #[test]
1379    fn builtin_resolve_scenarios_returns_values() {
1380        let diags = eval_builtin_policy(
1381            r#"
1382package builtin_test
1383import rego.v1
1384violation contains v if {
1385    scenarios := resolve_scenarios("MyBucket", "Tags.0.Value")
1386    count(scenarios) >= 1
1387    v := {"rule_id": "B_RS2", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1388}
1389"#,
1390            "B_RS2",
1391        );
1392        assert_eq!(diags.len(), 1, "resolve_scenarios should return at least one scenario");
1393    }
1394
1395    #[test]
1396    fn builtin_is_satisfiable_with_compatible_assumptions() {
1397        let diags = eval_builtin_policy(
1398            r#"
1399package builtin_test
1400import rego.v1
1401violation contains v if {
1402    is_satisfiable({"IsProd": true})
1403    v := {"rule_id": "B_SAT", "severity": "error", "message": "ok", "resource_id": ""}
1404}
1405"#,
1406            "B_SAT",
1407        );
1408        assert_eq!(diags.len(), 1, "IsProd=true should be satisfiable");
1409    }
1410
1411    #[test]
1412    fn builtin_make_diag_produces_diagnostic() {
1413        let diags = eval_builtin_policy(
1414            r#"
1415package builtin_test
1416import rego.v1
1417violation contains v if {
1418    v := make_diag("B_MD", "error", "MyBucket", "test message")
1419}
1420"#,
1421            "B_MD",
1422        );
1423        assert_eq!(diags.len(), 1);
1424        assert_eq!(diags[0].message, "test message");
1425    }
1426
1427    #[test]
1428    fn builtin_estimated_string_length_bounds_returns_both_bounds() {
1429        // `!If [IsProd, "production", "development"]` - the deployment picks one of
1430        // two known values, so the length is bounded but not fixed.
1431        let diags = eval_builtin_policy(
1432            r#"
1433package builtin_test
1434import rego.v1
1435violation contains v if {
1436    bounds := estimated_string_length_bounds("MyBucket", "Tags.0.Value")
1437    bounds.shortest == 10
1438    bounds.longest == 11
1439    v := {"rule_id": "B_ESL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1440}
1441"#,
1442            "B_ESL",
1443        );
1444        assert_eq!(diags.len(), 1, "a value chosen between two known strings must report both bounds");
1445    }
1446
1447    #[test]
1448    fn builtin_estimated_string_length_bounds_is_undefined_for_a_literal() {
1449        // Schema validation checks a literal against the constraint, so this rule
1450        // is not given one to estimate.
1451        let diags = eval_builtin_policy(
1452            r#"
1453package builtin_test
1454import rego.v1
1455violation contains v if {
1456    bounds := estimated_string_length_bounds("MyBucket", "BucketName")
1457    v := {"rule_id": "B_ESL_LITERAL", "severity": "error", "message": "ok", "resource_id": "MyBucket"}
1458}
1459"#,
1460            "B_ESL_LITERAL",
1461        );
1462        assert!(diags.is_empty(), "a literal string must yield no bounds, got {diags:?}");
1463    }
1464}