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
102const MERGED_DATA_INITIAL_CAPACITY: usize = 8 * 1024 * 1024;
104
105const KNOWN_RESOURCE_TYPES_PATH: &str = "data/known_resource_types";
108
109const GETATT_ATTRIBUTES_PATH: &str = "data/getatt_attributes";
111
112const PRIMARY_IDENTIFIERS_PATH: &str = "data/primary_identifiers";
114
115fn 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
133fn 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 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 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
175fn 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 registry_metadata: HashMap<String, RuleMetadataEntry>,
200 external_rule_metadata: HashMap<String, RuleMetadataEntry>,
202 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 #[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 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 {
239 let overlay_types: Vec<String> = overlay_catalog.type_names.clone();
243 let extended_known_types = extend_known_resource_types(&overlay_types)?;
244
245 let extended_getatt = extend_getatt_data(overlay_catalog)?;
247 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 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 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 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 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 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 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 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 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}