1use log::info;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use diagnostics::{Diagnostic, Entity, PhaseMetric, phase_metric};
6use rules::{RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, build_rule_metadata_map, is_valid_custom_rule_id};
7use schema_validator::{OverlayCatalog, SchemaMetadataCatalog, SchemaValidator, schema_metadata_catalog_with_overlays};
8use template_model::{SemanticModel, UNKNOWN_SPAN};
9use validation_engine::{
10 EngineConfig, GuardRuleSet, ValidateConfig, ValidationEngine, ValidationError, build_rule_list,
11 semantic_model_to_input_json,
12};
13
14use crate::rule_evaluator::GeneratedRuleRegistry;
15use crate::rules::{CachedData, EvalContext, NativeRuleRegistry};
16
17use serde::Deserialize;
18
19use cel_interpreter::{Context, Program, Value as CelValue};
20
21#[derive(Deserialize)]
22struct CustomRuleFile {
23 rules: Vec<CustomRuleDef>,
24}
25
26#[derive(Deserialize)]
27struct CustomRuleDef {
28 rule_id: String,
29 severity: Severity,
30 #[serde(default)]
31 category: Option<String>,
32 resource_type: Option<String>,
33 expression: String,
34 message: String,
35 #[serde(default)]
36 prop_path: Option<String>,
37 #[serde(default)]
38 suggested_fix: Option<String>,
39}
40
41pub struct CelEngine {
42 native_rules: NativeRuleRegistry,
43 generated_rules: GeneratedRuleRegistry,
44 custom_rules: Vec<CustomRule>,
45 registry_metadata: HashMap<String, RuleMetadataEntry>,
47 external_rule_metadata: HashMap<String, RuleMetadataEntry>,
49 guard_rules: Option<GuardRuleSet>,
52 cached_data: CachedData,
53 init_metric: PhaseMetric,
54}
55
56#[derive(Debug)]
57struct CustomRule {
58 rule_id: String,
59 severity: Severity,
60 category: Option<String>,
61 resource_type: Option<String>,
62 program: Program,
63 message: String,
64 prop_path: Option<String>,
65 suggested_fix: Option<String>,
66 source: RuleOrigin,
67}
68
69impl CelEngine {
70 pub fn new(config: EngineConfig) -> anyhow::Result<Self> {
71 let catalog =
72 config.build_overlay_catalog().map_err(|e| anyhow::anyhow!("Failed to build overlay catalog: {e}"))?;
73 let start = web_time::Instant::now();
74 let schema_metadata = schema_metadata_catalog_with_overlays(&catalog)?;
75 Self::new_from_parts(config, &catalog, schema_metadata, start)
76 }
77
78 #[doc(hidden)]
88 pub fn new_with_schema_validator(config: EngineConfig, validator: &SchemaValidator) -> anyhow::Result<Self> {
89 let start = web_time::Instant::now();
90 let schema_metadata = validator.schema_metadata_catalog()?;
91 Self::new_from_parts(config, validator.overlay_catalog(), schema_metadata, start)
92 }
93
94 fn new_from_parts(
97 config: EngineConfig,
98 overlay_catalog: &OverlayCatalog,
99 schema_metadata: Arc<SchemaMetadataCatalog>,
100 start: web_time::Instant,
101 ) -> anyhow::Result<Self> {
102 let native_rules = NativeRuleRegistry::new();
103 let generated_rules = GeneratedRuleRegistry::new()?;
104
105 let registry_metadata = build_rule_metadata_map();
106 let mut external_rule_metadata: HashMap<String, RuleMetadataEntry> = HashMap::new();
107
108 let guard_rules = GuardRuleSet::compile(&config.guard_rules).map_err(|error| anyhow::anyhow!(error))?;
109
110 let mut custom_rules = Vec::new();
111 for entry in &config.custom_rules {
112 match load_custom_rules(&entry.content, RuleOrigin::Custom) {
113 Ok(rules) => {
114 info!("Loaded {} custom rules from {}", rules.len(), entry.name);
115 for r in &rules {
116 external_rule_metadata.entry(r.rule_id.clone()).or_insert_with(|| RuleMetadataEntry {
117 category: r.category.clone(),
118 description: r.message.clone(),
119 severity: r.severity,
120 origin: RuleOrigin::Custom,
121 });
122 }
123 custom_rules.extend(rules);
124 }
125 Err(e) => {
126 return Err(anyhow::anyhow!("Failed to load rules from {}: {}", entry.name, e));
127 }
128 }
129 }
130
131 info!(
132 "CelEngine initialized: {} native rule fns, {} custom rules, {} Guard rules, {} registry + {} external \
133 metadata entries",
134 native_rules.rules.len(),
135 custom_rules.len(),
136 guard_rules.as_ref().map_or(0, |guard| guard.rule_metadata().len()),
137 registry_metadata.len(),
138 external_rule_metadata.len()
139 );
140 let mut cached_data = CachedData::load()?;
141 if !overlay_catalog.is_empty() {
145 cached_data.merge_overlay_catalog(overlay_catalog)?;
146 }
147 cached_data.set_schema_metadata(schema_metadata);
148 let init_metric = phase_metric(start);
149 Ok(CelEngine {
150 native_rules,
151 generated_rules,
152 custom_rules,
153 registry_metadata,
154 external_rule_metadata,
155 guard_rules,
156 cached_data,
157 init_metric,
158 })
159 }
160}
161
162impl ValidationEngine for CelEngine {
163 fn engine_name(&self) -> &str {
164 "cel"
165 }
166
167 fn evaluate_rules(
168 &self,
169 model: &Arc<SemanticModel>,
170 config: &ValidateConfig,
171 ) -> Result<Vec<Diagnostic>, ValidationError> {
172 let input_json = semantic_model_to_input_json(model)?;
173 let excluded_cats = config.filters.excluded_categories();
174 let region = config.pseudo_parameter_overrides.region.clone();
175
176 let mut diagnostics = if config.disable_builtin_rules {
177 Vec::new()
178 } else {
179 let ctx = EvalContext { model, input: &input_json, region: ®ion, cached_data: &self.cached_data };
180 let mut diags = self.native_rules.evaluate(&ctx, &excluded_cats);
181 let gen_diags = self.generated_rules.evaluate(model, &input_json, &excluded_cats);
182 diags.extend(gen_diags);
183 diags
184 };
185
186 for rule in &self.custom_rules {
187 if rule.category.as_deref().is_some_and(|c| excluded_cats.contains(c)) {
188 continue;
189 }
190 if let Some(ref rtype) = rule.resource_type {
191 for rid in model.resources_of_type(rtype) {
192 let cel_ctx = crate::functions::build_custom_context(&input_json, Some(rid), Some(model));
193 if execute_custom_rule(rule, &cel_ctx)? {
194 let msg = rule.message.replace("{name}", rid);
195 emit_custom_diagnostic(&mut diagnostics, rule, model, rid, &msg);
196 }
197 }
198 } else {
199 let cel_ctx = crate::functions::build_custom_context(&input_json, None, Some(model));
200 if execute_custom_rule(rule, &cel_ctx)? {
201 emit_custom_diagnostic(&mut diagnostics, rule, model, "", &rule.message);
202 }
203 }
204 }
205
206 if let Some(guard_rules) = &self.guard_rules {
207 diagnostics.extend(guard_rules.evaluate(model)?);
208 }
209
210 Ok(diagnostics)
211 }
212
213 fn list_rules(&self) -> Vec<RuleInfo> {
214 build_rule_list(&self.registry_metadata, &self.external_rule_metadata())
215 }
216
217 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
218 &self.registry_metadata
219 }
220
221 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
225 let mut merged = self.external_rule_metadata.clone();
226 if let Some(guard_rules) = &self.guard_rules {
227 for (rule_id, entry) in guard_rules.rule_metadata() {
228 merged.entry(rule_id.clone()).or_insert_with(|| entry.clone());
229 }
230 }
231 merged
232 }
233
234 fn init_metric(&self) -> &PhaseMetric {
235 &self.init_metric
236 }
237}
238
239fn execute_custom_rule(rule: &CustomRule, cel_ctx: &Context<'static>) -> Result<bool, ValidationError> {
240 match rule.program.execute(cel_ctx) {
241 Ok(CelValue::Bool(fired)) => Ok(fired),
242 Ok(_) => Err(ValidationError::Engine(format!(
247 "Custom rule '{}' expression must evaluate to a boolean, but produced a non-boolean value",
248 rule.rule_id
249 ))),
250 Err(error) => {
251 Err(ValidationError::Engine(format!("Custom rule '{}' failed to evaluate: {error}", rule.rule_id)))
252 }
253 }
254}
255
256fn emit_custom_diagnostic(
260 out: &mut Vec<Diagnostic>,
261 rule: &CustomRule,
262 model: &Arc<SemanticModel>,
263 rid: &str,
264 msg: &str,
265) {
266 let span =
267 if rid.is_empty() { UNKNOWN_SPAN } else { model.resource_span(rid, rule.prop_path.as_deref().unwrap_or("")) };
268 out.push(Diagnostic {
269 rule_id: rule.rule_id.clone(),
270 severity: rule.severity,
271 message: msg.to_string(),
272 entity: Entity::resource(rid, model.resources.get(rid).map(|r| r.resource_type.clone())),
273 property_path: rule.prop_path.clone(),
274 suggested_fix: rule.suggested_fix.clone(),
275 documentation_url: None,
276 category: rule.category.clone(),
277 location: if span == UNKNOWN_SPAN { None } else { Some(span) },
278 related_resources: None,
279 condition_scenario: None,
280 rule_description: None,
281 phase: None,
282 context: None,
283 source: rule.source,
284 });
285}
286
287fn load_custom_rules(source: &str, origin: RuleOrigin) -> anyhow::Result<Vec<CustomRule>> {
288 let file: CustomRuleFile = serde_json::from_str(source)?;
289 let mut rules = Vec::new();
290 for def in file.rules {
291 if def.rule_id.trim().is_empty() {
295 return Err(anyhow::anyhow!("Custom rule has an empty 'rule_id'"));
296 }
297 if !is_valid_custom_rule_id(&def.rule_id) {
302 return Err(anyhow::anyhow!(
303 "Custom rule '{}' has an invalid 'rule_id': only letters, digits, and the separators '_', '.', '-' \
304 are allowed",
305 def.rule_id
306 ));
307 }
308 if def.message.trim().is_empty() {
309 return Err(anyhow::anyhow!("Custom rule '{}' has an empty 'message'", def.rule_id));
310 }
311 if def.expression.trim().is_empty() {
312 return Err(anyhow::anyhow!("Custom rule '{}' has an empty 'expression'", def.rule_id));
313 }
314 let program = Program::compile(&def.expression)
315 .map_err(|e| anyhow::anyhow!("Failed to compile CEL expression for rule '{}': {}", def.rule_id, e))?;
316 for function in program.references().functions() {
321 if !crate::functions::is_supported_function(function) {
322 return Err(anyhow::anyhow!(
323 "Custom rule '{}' references unknown function '{}'",
324 def.rule_id,
325 function
326 ));
327 }
328 }
329 rules.push(CustomRule {
330 rule_id: def.rule_id,
331 severity: def.severity,
332 category: def.category,
333 resource_type: def.resource_type,
334 program,
335 message: def.message,
336 prop_path: def.prop_path,
337 suggested_fix: def.suggested_fix,
338 source: origin,
339 });
340 }
341 Ok(rules)
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use validation_engine::ExternalRuleSource;
348
349 #[test]
350 fn load_custom_rules_valid_json() {
351 let json = r#"{"rules": [
352 {
353 "rule_id": "CUSTOM001",
354 "severity": "ERROR",
355 "resource_type": "AWS::S3::Bucket",
356 "expression": "has(properties.BucketEncryption)",
357 "message": "Encryption required"
358 }
359 ]}"#;
360 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
361 assert_eq!(rules.len(), 1);
362 assert_eq!(rules[0].rule_id, "CUSTOM001");
363 assert!(matches!(rules[0].severity, Severity::Error));
364 assert_eq!(rules[0].resource_type, Some("AWS::S3::Bucket".into()));
365 assert_eq!(rules[0].category, None);
366 }
367
368 #[test]
369 fn load_custom_rules_severity_mapping() {
370 let make = |sev: &str| -> String {
371 format!(
372 r#"{{"rules": [{{"rule_id": "R1", "severity": "{}", "expression": "true", "message": "m"}}]}}"#,
373 sev
374 )
375 };
376
377 let error_rules = load_custom_rules(&make(Severity::Error.as_str()), RuleOrigin::Custom).unwrap();
378 assert_eq!(error_rules[0].severity, Severity::Error);
379
380 let warn_rules = load_custom_rules(&make(Severity::Warn.as_str()), RuleOrigin::Custom).unwrap();
381 assert_eq!(warn_rules[0].severity, Severity::Warn);
382
383 let info_rules = load_custom_rules(&make(Severity::Info.as_str()), RuleOrigin::Custom).unwrap();
384 assert_eq!(info_rules[0].severity, Severity::Info);
385 }
386
387 #[test]
388 fn load_custom_rules_custom_category() {
389 let json = r#"{"rules": [{
390 "rule_id": "R1",
391 "severity": "ERROR",
392 "category": "security",
393 "expression": "true",
394 "message": "m"
395 }]}"#;
396 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
397 assert_eq!(rules[0].category, Some("security".into()));
398 }
399
400 #[test]
401 fn load_custom_rules_optional_fields() {
402 let json = r#"{"rules": [{
403 "rule_id": "R1",
404 "severity": "ERROR",
405 "expression": "true",
406 "message": "m",
407 "prop_path": "Properties.X",
408 "suggested_fix": "Add X"
409 }]}"#;
410 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
411 assert_eq!(rules[0].prop_path, Some("Properties.X".into()));
412 assert_eq!(rules[0].suggested_fix, Some("Add X".into()));
413 }
414
415 #[test]
416 fn load_custom_rules_global_rule_no_resource_type() {
417 let json = r#"{"rules": [{
418 "rule_id": "R1",
419 "severity": "ERROR",
420 "expression": "true",
421 "message": "m"
422 }]}"#;
423 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
424 assert_eq!(rules[0].resource_type, None);
425 }
426
427 #[test]
428 fn load_custom_rules_invalid_json() {
429 let result = load_custom_rules("not json", RuleOrigin::Custom);
430 result.unwrap_err();
431 }
432
433 #[test]
434 fn load_custom_rules_bad_cel_expression() {
435 let json = r#"{"rules": [{
436 "rule_id": "R1",
437 "severity": "ERROR",
438 "expression": "((( invalid cel",
439 "message": "m"
440 }]}"#;
441 let result = load_custom_rules(json, RuleOrigin::Custom);
442 let err_msg = format!("{}", result.unwrap_err());
443 assert!(err_msg.contains("R1"), "Error should mention rule_id, got: {}", err_msg);
444 }
445
446 #[test]
447 fn load_custom_rules_empty_rules_array() {
448 let json = r#"{"rules": []}"#;
449 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
450 assert!(rules.is_empty());
451 }
452
453 #[test]
454 fn load_custom_rules_empty_rule_id_is_rejected() {
455 let json = r#"{"rules": [{"rule_id": "", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
456 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
457 assert!(err.contains("empty 'rule_id'"), "an empty rule_id must be rejected, got: {err}");
458 }
459
460 #[test]
461 fn load_custom_rules_blank_rule_id_is_rejected() {
462 let json = r#"{"rules": [{"rule_id": " ", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
463 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
464 assert!(err.contains("empty 'rule_id'"), "a whitespace-only rule_id must be rejected, got: {err}");
465 }
466
467 #[test]
468 fn load_custom_rules_arbitrary_alphanumeric_id_with_separators_is_accepted() {
469 let json = r#"{"rules": [{"rule_id": "s3.encryption-required_1", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
472 let rules = load_custom_rules(json, RuleOrigin::Custom).expect("an alphanumeric+separator id must load");
473 assert_eq!(rules[0].rule_id, "s3.encryption-required_1");
474 }
475
476 #[test]
477 fn load_custom_rules_rule_id_with_space_or_punctuation_is_rejected() {
478 let json = r#"{"rules": [{"rule_id": "bad id", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
479 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
480 assert!(err.contains("invalid 'rule_id'"), "a rule_id with a space must be rejected, got: {err}");
481
482 let json = r#"{"rules": [{"rule_id": "bad/id", "severity": "ERROR", "expression": "true", "message": "m"}]}"#;
483 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
484 assert!(err.contains("invalid 'rule_id'"), "a rule_id with punctuation must be rejected, got: {err}");
485 }
486
487 #[test]
488 fn load_custom_rules_empty_message_is_rejected() {
489 let json = r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": ""}]}"#;
490 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
491 assert!(err.contains("R1") && err.contains("empty 'message'"), "an empty message must be rejected, got: {err}");
492 }
493
494 #[test]
495 fn load_custom_rules_empty_expression_is_rejected() {
496 let json = r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": " ", "message": "m"}]}"#;
497 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
498 assert!(
499 err.contains("R1") && err.contains("empty 'expression'"),
500 "a blank expression must be rejected, got: {err}"
501 );
502 }
503
504 #[test]
505 fn load_custom_rules_unknown_function_is_rejected_at_load() {
506 let json = r#"{"rules": [{
509 "rule_id": "R1",
510 "severity": "ERROR",
511 "resource_type": "AWS::S3::Bucket",
512 "expression": "totally_unknown_fn(properties.BucketName)",
513 "message": "m"
514 }]}"#;
515 let err = format!("{}", load_custom_rules(json, RuleOrigin::Custom).unwrap_err());
516 assert!(
517 err.contains("R1") && err.contains("unknown function") && err.contains("totally_unknown_fn"),
518 "an unknown function reference must be rejected at load, got: {err}"
519 );
520 }
521
522 #[test]
523 fn load_custom_rules_standard_functions_and_macros_are_accepted() {
524 let json = r#"{"rules": [{
527 "rule_id": "R1",
528 "severity": "ERROR",
529 "expression": "has(resource.Properties) && size(resources) > 0 && [1, 2].all(x, x > 0)",
530 "message": "m"
531 }]}"#;
532 let rules = load_custom_rules(json, RuleOrigin::Custom).expect("standard functions and macros must load");
533 assert_eq!(rules.len(), 1);
534 }
535
536 #[test]
537 fn load_custom_rules_type_function_is_accepted() {
538 let json = r#"{"rules": [{
541 "rule_id": "R1",
542 "severity": "ERROR",
543 "resource_type": "AWS::S3::Bucket",
544 "expression": "type(resource) == \"map\"",
545 "message": "m"
546 }]}"#;
547 let rules = load_custom_rules(json, RuleOrigin::Custom).expect("type() must be an accepted function");
548 assert_eq!(rules.len(), 1);
549 }
550
551 #[test]
552 fn load_custom_rules_multiple_rules() {
553 let json = r#"{"rules": [
554 {"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": "m1"},
555 {"rule_id": "R2", "severity": "WARN", "expression": "false", "message": "m2"}
556 ]}"#;
557 let rules = load_custom_rules(json, RuleOrigin::Custom).unwrap();
558 assert_eq!(rules.len(), 2);
559 assert_eq!(rules[0].rule_id, "R1");
560 assert_eq!(rules[1].rule_id, "R2");
561 }
562
563 fn single_rule(source: &str, origin: RuleOrigin) -> CustomRule {
564 load_custom_rules(source, origin).expect("rule should load").pop().expect("one rule")
565 }
566
567 #[test]
568 fn execute_custom_rule_boolean_true_fires() {
569 let rule = single_rule(
570 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "true", "message": "m"}]}"#,
571 RuleOrigin::Custom,
572 );
573 let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
574 assert!(execute_custom_rule(&rule, &ctx).expect("boolean expression evaluates"));
575 }
576
577 #[test]
578 fn execute_custom_rule_boolean_false_does_not_fire() {
579 let rule = single_rule(
580 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "false", "message": "m"}]}"#,
581 RuleOrigin::Custom,
582 );
583 let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
584 assert!(!execute_custom_rule(&rule, &ctx).expect("boolean expression evaluates"));
585 }
586
587 #[test]
588 fn execute_custom_rule_non_boolean_result_is_error() {
589 let rule = single_rule(
592 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "\"a string\"", "message": "m"}]}"#,
593 RuleOrigin::Custom,
594 );
595 let ctx = crate::functions::build_custom_context(&serde_json::json!({}), None, None);
596 let err = execute_custom_rule(&rule, &ctx).expect_err("a non-boolean result must error");
597 match err {
598 ValidationError::Engine(message) => {
599 assert!(message.contains("R1") && message.contains("boolean"), "got: {message}");
600 }
601 other => panic!("expected Engine error, got {other:?}"),
602 }
603 }
604
605 #[test]
606 fn execute_custom_rule_evaluation_error_is_fatal_for_custom_origin() {
607 let rule = single_rule(
610 r#"{"rules": [{"rule_id": "R1", "severity": "ERROR", "expression": "resource.Missing.Deep == \"x\"", "message": "m"}]}"#,
611 RuleOrigin::Custom,
612 );
613 let ctx = crate::functions::build_custom_context(&serde_json::json!({"resources": {}}), Some("Bucket"), None);
614 execute_custom_rule(&rule, &ctx).expect_err("a custom-rule evaluation error must be fatal");
615 }
616
617 #[test]
618 fn shared_engine_keeps_concurrent_cel_evaluations_template_local() {
619 const THREAD_COUNT: usize = 8;
620 const ITERATIONS_PER_THREAD: usize = 128;
621 let custom_rule = r#"{"rules": [{
622 "rule_id": "CELISOLATIONPROBE",
623 "severity": "INFO",
624 "resource_type": "Custom::Probe",
625 "expression": "properties.Value == \"match\"",
626 "message": "{name}"
627 }]}"#;
628 let engine = Arc::new(
629 CelEngine::new(EngineConfig {
630 custom_rules: vec![ExternalRuleSource {
631 name: "cel_isolation.json".into(),
632 content: custom_rule.into(),
633 }],
634 ..Default::default()
635 })
636 .unwrap(),
637 );
638 let barrier = Arc::new(std::sync::Barrier::new(THREAD_COUNT));
639 let handles: Vec<_> = (0..THREAD_COUNT)
640 .map(|index| {
641 let engine = Arc::clone(&engine);
642 let barrier = Arc::clone(&barrier);
643 std::thread::spawn(move || {
644 let resource_name = format!("Probe{index}");
645 let template = format!(
646 "Resources:\n {resource_name}:\n Type: Custom::Probe\n Properties:\n Value: match\n"
647 );
648 let model = Arc::new(SemanticModel::from_bytes(template.as_bytes()).unwrap());
649 barrier.wait();
650 for _ in 0..ITERATIONS_PER_THREAD {
651 let diagnostics = engine.evaluate_rules(&model, &ValidateConfig::default()).unwrap();
652 let probes: Vec<&Diagnostic> =
653 diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "CELISOLATIONPROBE").collect();
654 assert_eq!(probes.len(), 1);
655 assert_eq!(probes[0].message, resource_name);
656 assert_eq!(probes[0].resource_logical_id(), Some(resource_name.as_str()));
657 }
658 })
659 })
660 .collect();
661 for handle in handles {
662 handle.join().unwrap();
663 }
664 }
665}