1use cel_engine::CelEngine;
2use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
3use rego_engine::RegoEngine;
4use rules::{RuleInfo, RuleMetadataEntry};
5use schema_validator::SchemaValidator;
6use std::collections::HashMap;
7use std::sync::Arc;
8use template_model::SemanticModel;
9use validation_engine::{
10 CompositeEngineConfig, EngineConfig, ValidateConfig, ValidationEngine, ValidationError, build_rule_list,
11};
12use web_time::Instant;
13
14pub struct CompositeEngine {
26 builtin_engine: CelEngine,
27 external_engine: Option<RegoEngine>,
28 init_metric: PhaseMetric,
29}
30
31impl CompositeEngine {
32 pub fn new(config: CompositeEngineConfig) -> anyhow::Result<Self> {
36 let start = Instant::now();
37 let (builtin_config, external_config) = split_configs(config);
38 let builtin_engine = CelEngine::new(builtin_config)?;
39 let external_engine = external_config.map(RegoEngine::new_external_only).transpose()?;
40 Ok(Self { builtin_engine, external_engine, init_metric: phase_metric(start) })
41 }
42
43 #[doc(hidden)]
50 pub fn new_with_schema_validator(
51 config: CompositeEngineConfig,
52 validator: &SchemaValidator,
53 ) -> anyhow::Result<Self> {
54 let start = Instant::now();
55 let config = CompositeEngineConfig { schema_validator_config: None, ..config };
56 let (builtin_config, external_config) = split_configs(config);
57 let builtin_engine = CelEngine::new_with_schema_validator(builtin_config, validator)?;
58 let external_engine = external_config
59 .map(|config| RegoEngine::new_external_only_with_schema_validator(config, validator))
60 .transpose()?;
61 Ok(Self { builtin_engine, external_engine, init_metric: phase_metric(start) })
62 }
63}
64
65fn split_configs(config: CompositeEngineConfig) -> (EngineConfig, Option<EngineConfig>) {
73 let CompositeEngineConfig { rego_rules, cel_rules, guard_rules, schema_validator_config } = config;
74 let external_config = (!rego_rules.is_empty()).then(|| EngineConfig {
75 custom_rules: rego_rules,
76 guard_rules: Vec::new(),
77 schema_validator_config: schema_validator_config.clone(),
78 });
79 let builtin_config = EngineConfig { custom_rules: cel_rules, guard_rules, schema_validator_config };
80 (builtin_config, external_config)
81}
82
83impl ValidationEngine for CompositeEngine {
84 fn engine_name(&self) -> &str {
85 "composite"
86 }
87
88 fn evaluate_rules(
89 &self,
90 model: &Arc<SemanticModel>,
91 config: &ValidateConfig,
92 ) -> Result<Vec<Diagnostic>, ValidationError> {
93 let mut diagnostics = self.builtin_engine.evaluate_rules(model, config)?;
99 if let Some(external_engine) = &self.external_engine {
100 diagnostics.extend(external_engine.evaluate_rules(model, config)?);
101 }
102 Ok(diagnostics)
103 }
104
105 fn list_rules(&self) -> Vec<RuleInfo> {
106 build_rule_list(self.builtin_engine.rule_metadata(), &self.external_rule_metadata())
107 }
108
109 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
110 self.builtin_engine.rule_metadata()
111 }
112
113 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
118 let mut merged = self.builtin_engine.external_rule_metadata();
119 if let Some(external_engine) = &self.external_engine {
120 merged.extend(external_engine.external_rule_metadata());
121 }
122 merged
123 }
124
125 fn init_metric(&self) -> &PhaseMetric {
126 &self.init_metric
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use rules::{RuleOrigin, Severity};
134 use std::collections::HashSet;
135 use validation_engine::ExternalRuleSource;
136
137 const BUCKET_TEMPLATE: &str = r#"
138AWSTemplateFormatVersion: "2010-09-09"
139Resources:
140 MyBucket:
141 Type: AWS::S3::Bucket
142 Properties:
143 BucketName: my-bucket
144"#;
145
146 const BUCKET_WITHOUT_NAME_TEMPLATE: &str = r#"
147AWSTemplateFormatVersion: "2010-09-09"
148Resources:
149 MyBucket:
150 Type: AWS::S3::Bucket
151"#;
152
153 fn model(yaml: &str) -> Arc<SemanticModel> {
154 Arc::new(SemanticModel::from_bytes(yaml.as_bytes()).expect("template must parse"))
155 }
156
157 type Fingerprint = (String, Severity, Option<(u32, u32, u32, u32)>, Option<String>, String);
161
162 fn fingerprints(diagnostics: &[Diagnostic]) -> Vec<Fingerprint> {
163 let mut projected: Vec<Fingerprint> = diagnostics
164 .iter()
165 .map(|d| {
166 let location = d.location.as_ref().map(|s| (s.start_line, s.start_column, s.end_line, s.end_column));
167 (d.rule_id.clone(), d.severity, location, d.property_path.clone(), d.message.clone())
168 })
169 .collect();
170 projected.sort();
171 projected
172 }
173
174 fn custom_rego_rule() -> ExternalRuleSource {
175 ExternalRuleSource {
176 name: "composite_custom.rego".into(),
177 content: r#"
178package composite_custom
179import rego.v1
180
181violation contains v if {
182 some name, res in input.resources
183 res.resourceType == "AWS::S3::Bucket"
184 v := {"rule_id": "COMPOSITE_CUSTOM", "severity": "error", "message": "custom rego fired", "resource_id": name}
185}
186"#
187 .into(),
188 }
189 }
190
191 fn bucket_name_exists_guard() -> ExternalRuleSource {
192 ExternalRuleSource {
193 name: "bucket_name.guard".into(),
194 content: r#"
195rule check_bucket_name {
196 AWS::S3::Bucket {
197 Properties.BucketName EXISTS
198 <<BucketName must be specified>>
199 }
200}
201"#
202 .into(),
203 }
204 }
205
206 fn custom_cel_rule() -> ExternalRuleSource {
209 ExternalRuleSource {
210 name: "composite_custom.json".into(),
211 content: r#"{"rules": [{
212 "rule_id": "COMPOSITE_CEL",
213 "severity": "ERROR",
214 "resource_type": "AWS::S3::Bucket",
215 "expression": "true",
216 "message": "custom cel fired"
217 }]}"#
218 .into(),
219 }
220 }
221
222 fn builtin_only_diagnostics(model: &Arc<SemanticModel>, config: &ValidateConfig) -> Vec<Diagnostic> {
223 CelEngine::new(EngineConfig::default())
224 .expect("built-in engine builds")
225 .evaluate_rules(model, config)
226 .expect("built-in engine evaluates")
227 }
228
229 #[test]
230 fn without_external_rules_no_external_engine_and_builtins_match_cel() {
231 let composite = CompositeEngine::new(CompositeEngineConfig::default()).expect("composite builds");
232 assert!(composite.external_engine.is_none(), "with no external rules there must be no external engine");
233
234 let model = model(BUCKET_TEMPLATE);
235 let config = ValidateConfig::default();
236 let composite_diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
237
238 assert_eq!(
239 fingerprints(&composite_diags),
240 fingerprints(&builtin_only_diagnostics(&model, &config)),
241 "with no external rules the composite must produce exactly the built-in diagnostics"
242 );
243 }
244
245 #[test]
246 fn custom_rego_adds_one_finding_and_builtins_appear_exactly_once() {
247 let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([custom_rego_rule()]))
248 .expect("composite builds");
249 assert!(composite.external_engine.is_some(), "custom rego rules must construct the external engine");
250
251 let model = model(BUCKET_TEMPLATE);
252 let config = ValidateConfig::default();
253 let composite_diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
254
255 let custom: Vec<&Diagnostic> = composite_diags.iter().filter(|d| d.rule_id == "COMPOSITE_CUSTOM").collect();
256 assert_eq!(custom.len(), 1, "the custom rego rule must contribute exactly one finding");
257 assert_eq!(custom[0].severity, Severity::Error);
258 assert_eq!(custom[0].source, RuleOrigin::Custom);
259 assert_eq!(custom[0].message, "custom rego fired");
260
261 let builtins: Vec<Diagnostic> =
264 composite_diags.iter().filter(|d| d.rule_id != "COMPOSITE_CUSTOM").cloned().collect();
265 assert_eq!(
266 fingerprints(&builtins),
267 fingerprints(&builtin_only_diagnostics(&model, &config)),
268 "built-in diagnostics must appear exactly once, matching a standalone built-in run"
269 );
270 }
271
272 #[test]
273 fn disabling_builtins_returns_only_external_findings() {
274 let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([custom_rego_rule()]))
275 .expect("composite builds");
276 let model = model(BUCKET_TEMPLATE);
277 let config = ValidateConfig { disable_builtin_rules: true, ..ValidateConfig::default() };
278
279 let diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
280 assert_eq!(diags.len(), 1, "only the external finding must remain when built-ins are disabled");
281 assert_eq!(diags[0].rule_id, "COMPOSITE_CUSTOM");
282 assert_eq!(diags[0].source, RuleOrigin::Custom);
283 }
284
285 #[test]
286 fn custom_cel_adds_one_finding_and_builtins_appear_exactly_once() {
287 let composite = CompositeEngine::new(CompositeEngineConfig::new().with_cel_rules([custom_cel_rule()]))
288 .expect("composite builds");
289 assert!(composite.external_engine.is_none(), "a CEL-only custom rule must not construct the external engine");
292
293 let model = model(BUCKET_TEMPLATE);
294 let config = ValidateConfig::default();
295 let composite_diags = composite.evaluate_rules(&model, &config).expect("composite evaluates");
296
297 let custom: Vec<&Diagnostic> = composite_diags.iter().filter(|d| d.rule_id == "COMPOSITE_CEL").collect();
298 assert_eq!(custom.len(), 1, "the custom cel rule must contribute exactly one finding");
299 assert_eq!(custom[0].severity, Severity::Error);
300 assert_eq!(custom[0].source, RuleOrigin::Custom);
301 assert_eq!(custom[0].message, "custom cel fired");
302
303 let builtins: Vec<Diagnostic> =
304 composite_diags.iter().filter(|d| d.rule_id != "COMPOSITE_CEL").cloned().collect();
305 assert_eq!(
306 fingerprints(&builtins),
307 fingerprints(&builtin_only_diagnostics(&model, &config)),
308 "built-in diagnostics must appear exactly once alongside the custom CEL finding"
309 );
310 }
311
312 #[test]
313 fn cel_rego_and_guard_custom_rules_all_fire_together() {
314 let composite = CompositeEngine::new(
315 CompositeEngineConfig::new()
316 .with_cel_rules([custom_cel_rule()])
317 .with_rego_rules([custom_rego_rule()])
318 .with_guard_rules([bucket_name_exists_guard()]),
319 )
320 .expect("composite builds");
321 assert!(composite.external_engine.is_some(), "custom Rego rules must construct the external engine");
322
323 let model = model(BUCKET_WITHOUT_NAME_TEMPLATE);
326 let diags = composite.evaluate_rules(&model, &ValidateConfig::default()).expect("composite evaluates");
327
328 assert_eq!(
329 diags.iter().filter(|d| d.rule_id == "COMPOSITE_CEL").count(),
330 1,
331 "the custom CEL rule must fire once"
332 );
333 assert_eq!(
334 diags.iter().filter(|d| d.rule_id == "COMPOSITE_CUSTOM").count(),
335 1,
336 "the custom Rego rule must fire once"
337 );
338 assert_eq!(
339 diags.iter().filter(|d| d.rule_id == "check_bucket_name").count(),
340 1,
341 "the Guard rule must fire once"
342 );
343 }
344
345 #[test]
346 fn list_rules_includes_cel_custom_rules() {
347 let composite = CompositeEngine::new(CompositeEngineConfig::new().with_cel_rules([custom_cel_rule()]))
348 .expect("composite builds");
349 let ids: HashSet<String> = composite.list_rules().into_iter().map(|r| r.id).collect();
350 assert!(ids.contains("COMPOSITE_CEL"), "composite listRules must include the custom CEL rule");
351 }
352
353 #[test]
354 fn guard_exists_does_not_fire_when_property_present() {
355 let composite =
356 CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([bucket_name_exists_guard()]))
357 .expect("composite builds");
358 assert!(
359 composite.external_engine.is_none(),
360 "Guard rules are evaluated by the built-in engine and must not construct the external engine"
361 );
362 let diags = composite.evaluate_rules(&model(BUCKET_TEMPLATE), &ValidateConfig::default()).expect("evaluates");
363 assert!(
364 !diags.iter().any(|d| d.rule_id == "check_bucket_name"),
365 "the guard EXISTS check must be satisfied when BucketName is present; got: {:?}",
366 diags.iter().map(|d| &d.rule_id).collect::<Vec<_>>()
367 );
368 }
369
370 #[test]
371 fn guard_exists_fires_once_when_property_absent() {
372 let composite =
373 CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([bucket_name_exists_guard()]))
374 .expect("composite builds");
375 let diags = composite
376 .evaluate_rules(&model(BUCKET_WITHOUT_NAME_TEMPLATE), &ValidateConfig::default())
377 .expect("evaluates");
378
379 let guard: Vec<&Diagnostic> = diags.iter().filter(|d| d.rule_id == "check_bucket_name").collect();
380 assert_eq!(guard.len(), 1, "the guard rule must fire once when BucketName is absent");
381 assert_eq!(guard[0].source, RuleOrigin::Guard);
382 }
383
384 #[test]
385 fn guard_nested_exists_matches_nested_property_presence() {
386 let guard_rule = ExternalRuleSource {
387 name: "nested_exists.guard".into(),
388 content: r#"
389rule check_versioning_status {
390 AWS::S3::Bucket {
391 Properties.VersioningConfiguration.Status EXISTS
392 <<Versioning status must be specified>>
393 }
394}
395"#
396 .into(),
397 };
398 let composite = CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([guard_rule]))
399 .expect("composite builds");
400 let present = model(
401 r#"
402Resources:
403 MyBucket:
404 Type: AWS::S3::Bucket
405 Properties:
406 VersioningConfiguration:
407 Status: Enabled
408"#,
409 );
410
411 let present_diagnostics = composite.evaluate_rules(&present, &ValidateConfig::default()).expect("evaluates");
412 assert!(
413 !present_diagnostics.iter().any(|diagnostic| diagnostic.rule_id == "check_versioning_status"),
414 "nested EXISTS must pass when the nested property is present"
415 );
416
417 let absent_diagnostics = composite
418 .evaluate_rules(&model(BUCKET_WITHOUT_NAME_TEMPLATE), &ValidateConfig::default())
419 .expect("evaluates");
420 assert_eq!(
421 absent_diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "check_versioning_status").count(),
422 1,
423 "nested EXISTS must fire once when the nested property is absent"
424 );
425 }
426
427 #[test]
428 fn list_rules_includes_builtins_and_external_rules() {
429 let composite =
430 CompositeEngine::new(CompositeEngineConfig::new().with_guard_rules([bucket_name_exists_guard()]))
431 .expect("composite builds");
432
433 let composite_ids: HashSet<String> = composite.list_rules().into_iter().map(|r| r.id).collect();
434 let builtin_ids: HashSet<String> = CelEngine::new(EngineConfig::default())
435 .expect("built-in engine builds")
436 .list_rules()
437 .into_iter()
438 .map(|r| r.id)
439 .collect();
440
441 assert!(!builtin_ids.is_empty(), "sanity: the built-in engine advertises rules");
442 assert!(builtin_ids.is_subset(&composite_ids), "composite listRules must include every built-in rule");
443 assert!(
444 composite_ids.contains("check_bucket_name"),
445 "composite listRules must include the external guard rule"
446 );
447 }
448
449 #[test]
450 fn init_metric_spans_both_engine_constructions() {
451 let composite = CompositeEngine::new(CompositeEngineConfig::new().with_rego_rules([custom_rego_rule()]))
452 .expect("composite builds");
453 let external = composite.external_engine.as_ref().expect("external engine constructed");
454
455 assert!(composite.init_metric().duration_ms > 0.0, "the init metric must be recorded");
456 assert!(
457 composite.init_metric().duration_ms >= external.init_metric().duration_ms,
458 "the composite init metric must span the external engine's construction, not replace it"
459 );
460 }
461}