1#[cfg(test)]
2use data_source::AdditionalSchemaSource;
3use data_source::embedded::{CFN_LINT_VERSION, RESOURCE_SCHEMA_VERSION};
4use diagnostics::{
5 BudgetExhaustionRecord, DetailLevel, Diagnostic, Entity, PerformanceMetrics, Phase, PhaseMetric,
6 RegisteredDiagnostic, RelatedResource, ReportMetadata, ReportStatus, ResourceRef, Summary, ValidationReport,
7 ViolationContext, apply_filters, diagnostic_from_parse_defect, phase_metric, resolve_section_span,
8};
9use rules::{
10 FilterConfig, RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, is_fatal_rule, is_valid_custom_rule_id,
11 rule_number,
12};
13use schema_validator::{
14 OverlayCatalog, SchemaValidationResult, SchemaValidator, SchemaValidatorConfig, build_overlay_catalog,
15};
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::collections::HashMap;
19use std::error;
20use std::fmt;
21use std::panic::{AssertUnwindSafe, catch_unwind};
22use std::sync::Arc;
23use template_model::resolved_value::effective_path_from_scenario_source_path;
24use template_model::{
25 EntityType, JsonValue, ParseConfig, ParseError, ParseResult, PseudoParameterOverrides, SemanticModel, SourceSpan,
26 UNKNOWN_SPAN, entity_identity, is_sam_transform_error_message, region_enums, span_to_option,
27};
28use web_time::Instant;
29
30pub const DIAGNOSTIC_SOURCE_PATH_FIELD: &str = "source_path";
31
32#[derive(Debug)]
33pub enum ValidationError {
34 Parse(ParseError),
35 Engine(String),
36}
37
38impl fmt::Display for ValidationError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 ValidationError::Parse(e) => write!(f, "{}", e),
42 ValidationError::Engine(msg) => write!(f, "{}", msg),
43 }
44 }
45}
46
47impl error::Error for ValidationError {
48 fn source(&self) -> Option<&(dyn error::Error + 'static)> {
49 match self {
50 ValidationError::Parse(e) => Some(e),
51 ValidationError::Engine(_) => None,
52 }
53 }
54}
55
56impl From<ParseError> for ValidationError {
57 fn from(e: ParseError) -> Self {
58 ValidationError::Parse(e)
59 }
60}
61
62impl From<String> for ValidationError {
63 fn from(s: String) -> Self {
64 ValidationError::Engine(s)
65 }
66}
67
68impl From<&str> for ValidationError {
69 fn from(s: &str) -> Self {
70 ValidationError::Engine(s.to_string())
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
77#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
78#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
79pub enum EngineType {
80 Rego,
81 Cel,
82 #[default]
87 Composite,
88}
89
90impl EngineType {
91 pub const fn as_str(&self) -> &'static str {
92 match self {
93 EngineType::Rego => "Rego",
94 EngineType::Cel => "CEL",
95 EngineType::Composite => "Composite",
96 }
97 }
98
99 pub fn parse(raw: &str) -> Result<Self, String> {
104 match raw.to_lowercase().as_str() {
105 "rego" => Ok(EngineType::Rego),
106 "cel" => Ok(EngineType::Cel),
107 "composite" => Ok(EngineType::Composite),
108 other => Err(format!("Unknown engine type '{other}'; expected 'rego', 'cel', or 'composite'")),
109 }
110 }
111}
112
113impl fmt::Display for EngineType {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 f.write_str(self.as_str())
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
127#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
128#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
129#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
130pub struct ExternalRuleSource {
131 pub name: String,
132 pub content: String,
133}
134
135#[derive(Default, Clone, Serialize, Deserialize)]
136#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
137#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
138#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
139#[serde(rename_all = "camelCase")]
140pub struct EngineConfig {
141 #[serde(default)]
143 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
144 pub custom_rules: Vec<ExternalRuleSource>,
145 #[serde(default)]
147 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
148 pub guard_rules: Vec<ExternalRuleSource>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
154 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
155 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
156 pub schema_validator_config: Option<SchemaValidatorConfig>,
157}
158
159impl EngineConfig {
160 pub fn new() -> Self {
162 Self::default()
163 }
164
165 pub fn with_custom_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
167 self.custom_rules.extend(rules);
168 self
169 }
170
171 pub fn with_guard_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
173 self.guard_rules.extend(rules);
174 self
175 }
176
177 pub fn with_schema_validator_config(mut self, config: SchemaValidatorConfig) -> Self {
181 self.schema_validator_config = Some(config);
182 self
183 }
184
185 #[doc(hidden)]
191 pub fn build_overlay_catalog(&self) -> Result<OverlayCatalog, ValidationError> {
192 let additional_schemas = match &self.schema_validator_config {
193 Some(cfg) if !cfg.additional_schemas.is_empty() => &cfg.additional_schemas,
194 _ => return Ok(OverlayCatalog::default()),
195 };
196 let overlays: Vec<(String, serde_json::Value)> = additional_schemas
197 .iter()
198 .map(|s| s.resolve().map_err(|e| ValidationError::Engine(e.0)))
199 .collect::<Result<Vec<_>, _>>()?;
200 build_overlay_catalog(overlays)
201 .map_err(|e| ValidationError::Engine(format!("Failed to apply an additional schema: {e}")))
202 }
203}
204
205#[derive(Default, Clone, Serialize, Deserialize)]
215#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
216#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
217#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
218#[serde(rename_all = "camelCase")]
219pub struct CompositeEngineConfig {
220 #[serde(default)]
222 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
223 pub rego_rules: Vec<ExternalRuleSource>,
224 #[serde(default)]
228 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
229 pub cel_rules: Vec<ExternalRuleSource>,
230 #[serde(default)]
232 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
233 pub guard_rules: Vec<ExternalRuleSource>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
239 #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
240 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
241 pub schema_validator_config: Option<SchemaValidatorConfig>,
242}
243
244impl CompositeEngineConfig {
245 pub fn new() -> Self {
247 Self::default()
248 }
249
250 pub fn with_rego_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
252 self.rego_rules.extend(rules);
253 self
254 }
255
256 pub fn with_cel_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
259 self.cel_rules.extend(rules);
260 self
261 }
262
263 pub fn with_guard_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
265 self.guard_rules.extend(rules);
266 self
267 }
268
269 pub fn with_schema_validator_config(mut self, config: SchemaValidatorConfig) -> Self {
272 self.schema_validator_config = Some(config);
273 self
274 }
275}
276
277#[derive(Clone, Default)]
278pub struct ValidateConfig {
279 pub filters: FilterConfig,
280 pub detail_level: DetailLevel,
281 pub severity_level: Severity,
282 pub parameter_overrides: HashMap<String, String>,
283 pub pseudo_parameter_overrides: PseudoParameterOverrides,
284 pub strict: bool,
286 pub disable_builtin_rules: bool,
289}
290
291pub trait ValidationEngine {
292 fn engine_name(&self) -> &str;
293
294 fn evaluate_rules(
295 &self,
296 model: &Arc<SemanticModel>,
297 config: &ValidateConfig,
298 ) -> Result<Vec<Diagnostic>, ValidationError>;
299
300 fn list_rules(&self) -> Vec<RuleInfo>;
301
302 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry>;
304
305 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry>;
307
308 fn init_metric(&self) -> &PhaseMetric;
309}
310
311pub(crate) fn validate(
312 engine: &dyn ValidationEngine,
313 schema_validator: &SchemaValidator,
314 result: ParseResult,
315 config: ValidateConfig,
316 file_path: String,
317) -> Result<ValidationReport, ValidationError> {
318 let model = Arc::new(result.model);
319 let model_build = PhaseMetric { duration_ms: result.model_build_ms };
320 log::info!(
321 "Validating: {} resources, {} types (engine={})",
322 model.resources.len(),
323 model.resources_by_type.len(),
324 engine.engine_name()
325 );
326
327 let schema_result = if config.disable_builtin_rules {
328 SchemaValidationResult { diagnostics: Vec::new(), metric: phase_metric(Instant::now()) }
329 } else {
330 schema_validator.validate(&model, config.pseudo_parameter_overrides.region.as_deref())
331 };
332 let mut all_diagnostics = schema_result.diagnostics;
333
334 if !config.disable_builtin_rules {
335 let invalid = config.pseudo_parameter_overrides.invalid_overrides();
336 if !invalid.is_empty() {
337 let message = format!("Invalid pseudo-parameter override(s): {}", invalid.join("; "));
338 all_diagnostics.push(RegisteredDiagnostic::new("W9012", message).build());
339 }
340 }
341
342 let t_eval = Instant::now();
343 let engine_diags = engine.evaluate_rules(&model, &config)?;
344 all_diagnostics.extend(engine_diags);
345 let eval_metric = phase_metric(t_eval);
346
347 let t_post = Instant::now();
348 if !config.disable_builtin_rules {
349 all_diagnostics.extend(crate::step_functions::validate_all_state_machines(&model));
350 all_diagnostics.extend(model.diagnostics.iter().map(diagnostic_from_parse_defect));
351
352 if config.pseudo_parameter_overrides.region.is_none() && region_enums::template_has_region_scoped_value(&model)
358 {
359 all_diagnostics.push(
360 RegisteredDiagnostic::new(
361 "I9003",
362 "No region supplied; region-scoped instance/node types were validated against all regions. \
363 A value reported valid here may still be unavailable in your target region - pass a region to \
364 validate against it specifically.",
365 )
366 .build(),
367 );
368 }
369 }
370
371 gate_sam_transform_errors(&mut all_diagnostics);
372 gate_cdk_suppressed_rules(&mut all_diagnostics, &model);
373
374 backfill_locations(&mut all_diagnostics, &model);
375 backfill_entities(&mut all_diagnostics, &model);
376
377 let registry_metadata = engine.rule_metadata();
378 let external_metadata = config.detail_level.needs_enrichment().then(|| engine.external_rule_metadata());
379 if let Some(external_metadata) = external_metadata.as_ref() {
380 enrich_diagnostics(&mut all_diagnostics, &model, registry_metadata, external_metadata, &config.detail_level);
381 }
382
383 if config.detail_level.needs_context() {
384 schema_validator.enrich_context(&mut all_diagnostics, &model);
385 for d in all_diagnostics.iter_mut() {
386 if d.context.is_none() && d.phase != Some(Phase::Schema) {
387 d.context = build_context(
388 &d.rule_id,
389 d.resource_logical_id(),
390 d.property_path.as_deref().unwrap_or(""),
391 &model,
392 );
393 }
394 }
395 }
396
397 let exhausted_kinds = model.exhausted_budget_kinds();
400 let budget_exhaustion_records: Vec<BudgetExhaustionRecord> = exhausted_kinds
401 .iter()
402 .map(|kind| BudgetExhaustionRecord {
403 kind: kind.as_str().to_string(),
404 description: kind.description().to_string(),
405 limit: kind.limit(),
406 analysis_incomplete: kind.analysis_incomplete(),
407 })
408 .collect();
409 let analysis_incomplete = model.budget_analysis_incomplete();
410
411 if !config.disable_builtin_rules && !exhausted_kinds.is_empty() {
412 let message = if analysis_incomplete {
413 "Deterministic validation budgets were exhausted; analysis may be incomplete and some findings may be omitted"
414 } else {
415 "Deterministic validation budgets were exhausted; analysis completed but some detail was omitted"
416 };
417 let mut budget_warning = RegisteredDiagnostic::new("W9052", message).build();
418 if config.detail_level.needs_context() {
419 let budget_context = budget_exhaustion_records
420 .iter()
421 .map(|record| {
422 serde_json::json!({
423 "kind": record.kind.as_str(),
424 "description": record.description.as_str(),
425 "limit": record.limit,
426 "analysisIncomplete": record.analysis_incomplete,
427 })
428 })
429 .collect();
430 let mut context_extra = HashMap::new();
431 context_extra.insert("budgetExhaustions".to_string(), serde_json::Value::Array(budget_context).into());
432 budget_warning.context = Some(ViolationContext {
433 actual_value: None,
434 expected_constraint: None,
435 property: None,
436 lifecycle: None,
437 resolution_source: None,
438 extra: Some(context_extra),
439 });
440 }
441
442 let warning_index = all_diagnostics.len();
443 all_diagnostics.push(budget_warning);
444 let warning_slice = &mut all_diagnostics[warning_index..];
445 backfill_locations(warning_slice, &model);
446 backfill_entities(warning_slice, &model);
447 if let Some(external_metadata) = external_metadata.as_ref() {
448 enrich_diagnostics(warning_slice, &model, registry_metadata, external_metadata, &config.detail_level);
449 }
450 }
451
452 let (total_before, suppressed) = finalize_diagnostics(&mut all_diagnostics, &config);
453
454 if suppressed > 0 {
455 log::info!("Filtered {} -> {} diagnostics ({} suppressed)", total_before, all_diagnostics.len(), suppressed);
456 }
457
458 let excluded_cats = config.filters.excluded_categories();
459 let active_rule_count = registry_metadata
460 .iter()
461 .filter(|(_, entry)| !entry.category.as_deref().is_some_and(|c| excluded_cats.contains(c)))
462 .count() as u32;
463
464 let mut report = build_report(
465 all_diagnostics,
466 &model,
467 suppressed,
468 Some(active_rule_count),
469 config.strict,
470 config.severity_level,
471 file_path,
472 );
473 if analysis_incomplete {
474 report.status = ReportStatus::AnalysisIncomplete;
475 }
476 report.metadata.budget_exhaustions = (!budget_exhaustion_records.is_empty()).then_some(budget_exhaustion_records);
477 let finalize_metric = phase_metric(t_post);
478
479 report.performance.schema_init = schema_validator.init_metric().clone();
480 report.performance.engine_init = engine.init_metric().clone();
481 report.performance.model_build = model_build;
482 report.performance.schema_validate = schema_result.metric;
483 report.performance.rule_evaluation = eval_metric;
484 report.performance.diagnostic_finalize = finalize_metric;
485
486 Ok(report)
487}
488
489struct DataSourceVersions {
490 cfn_lint_version: &'static str,
491 resource_schema_version: &'static str,
492}
493
494static DATA_SOURCE_VERSIONS: DataSourceVersions =
495 DataSourceVersions { cfn_lint_version: CFN_LINT_VERSION, resource_schema_version: RESOURCE_SCHEMA_VERSION };
496
497#[cfg(any(test, feature = "test"))]
498pub fn validate_bytes(
499 engine: &dyn ValidationEngine,
500 schema_validator: &SchemaValidator,
501 bytes: &[u8],
502 config: ValidateConfig,
503) -> Result<ValidationReport, ValidationError> {
504 validate_bytes_with_path(engine, schema_validator, bytes, config, String::new())
505}
506
507pub fn validate_bytes_with_path(
508 engine: &dyn ValidationEngine,
509 schema_validator: &SchemaValidator,
510 bytes: &[u8],
511 config: ValidateConfig,
512 file_path: String,
513) -> Result<ValidationReport, ValidationError> {
514 let total_start = Instant::now();
515 let result = match SemanticModel::parse(
516 bytes,
517 ParseConfig {
518 parameters: config.parameter_overrides.clone(),
519 pseudo_parameters: config.pseudo_parameter_overrides.clone(),
520 },
521 ) {
522 Ok(r) => r,
523 Err(e) => {
524 let span = match (e.line, e.column) {
525 (Some(l), Some(c)) => SourceSpan { start_line: l, start_column: c, end_line: l, end_column: c },
526 _ => UNKNOWN_SPAN,
527 };
528 let diag = RegisteredDiagnostic::new("F1101", e.message).location(span).phase(Phase::Parse).build();
529 let diags = vec![diag];
530 let report = ValidationReport {
531 file_path,
532 status: ReportStatus::Error,
533 version: env!("CARGO_PKG_VERSION").to_string(),
534 diagnostics: diags,
535 metadata: ReportMetadata {
536 rules_evaluated: 0,
537 cfn_lint_version: DATA_SOURCE_VERSIONS.cfn_lint_version.to_owned(),
538 resource_schema_version: DATA_SOURCE_VERSIONS.resource_schema_version.to_owned(),
539 resources_scanned: 0,
540 counts: Summary { fatal: 1, errors: 0, warnings: 0, informational: 0, debug: 0 },
541 suppressed: 0,
542 strict: config.strict,
543 severity_level: config.severity_level,
544 budget_exhaustions: None,
545 },
546 performance: PerformanceMetrics {
547 schema_init: PhaseMetric { duration_ms: 0.0 },
548 engine_init: PhaseMetric { duration_ms: 0.0 },
549 model_build: PhaseMetric { duration_ms: 0.0 },
550 schema_validate: PhaseMetric { duration_ms: 0.0 },
551 rule_evaluation: PhaseMetric { duration_ms: 0.0 },
552 diagnostic_finalize: PhaseMetric { duration_ms: 0.0 },
553 validate_total: phase_metric(total_start),
554 },
555 };
556 return Ok(report);
557 }
558 };
559 let mut report = validate(engine, schema_validator, result, config, file_path)?;
560 report.performance.validate_total = phase_metric(total_start);
561 Ok(report)
562}
563
564pub fn catch_panics<T, E>(
588 operation: impl FnOnce() -> Result<T, E>,
589 on_panic: impl FnOnce(String) -> E,
590) -> Result<T, E> {
591 match catch_unwind(AssertUnwindSafe(operation)) {
592 Ok(result) => result,
593 Err(payload) => Err(on_panic(panic_message(payload.as_ref()))),
594 }
595}
596
597pub fn validate_catching_panics<F>(validate: F) -> Result<ValidationReport, ValidationError>
600where
601 F: FnOnce() -> Result<ValidationReport, ValidationError>,
602{
603 catch_panics(validate, |message| ValidationError::Engine(format!("Internal validation error: {message}")))
604}
605
606pub fn semantic_model_to_input_json(model: &SemanticModel) -> Result<serde_json::Value, ValidationError> {
607 serde_json::to_value(model.to_diagnostic_json()).map_err(|e| {
608 ValidationError::Engine(format!("Failed to serialize the semantic model for rule evaluation: {e}"))
609 })
610}
611
612fn panic_message(payload: &(dyn Any + Send)) -> String {
613 if let Some(message) = payload.downcast_ref::<&str>() {
614 (*message).to_string()
615 } else if let Some(message) = payload.downcast_ref::<String>() {
616 message.clone()
617 } else {
618 "Panic payload was not a string".to_string()
619 }
620}
621
622fn parse_span_coordinate(
623 diagnostic: &serde_json::Value,
624 field_name: &str,
625 rule_id: &str,
626) -> Result<Option<u32>, String> {
627 let Some(value) = diagnostic.get(field_name) else {
628 return Ok(None);
629 };
630 if value.is_null() {
631 return Ok(None);
632 }
633 let coordinate = value
634 .as_u64()
635 .ok_or_else(|| format!("Diagnostic '{}' field '{}' must be a non-negative integer", rule_id, field_name))?;
636 u32::try_from(coordinate)
637 .map(Some)
638 .map_err(|_| format!("Diagnostic '{}' field '{}' exceeds the supported coordinate range", rule_id, field_name))
639}
640
641fn resolve_model_diagnostic_span(
642 rule_id: &str,
643 model: &SemanticModel,
644 resource_id: Option<&str>,
645 property_path: Option<&str>,
646 source_path: Option<&str>,
647) -> Result<SourceSpan, String> {
648 if let Some(source_path) = source_path {
649 let resource_id = resource_id.ok_or_else(|| {
650 format!("Diagnostic '{}' provides '{}' without a resource_id", rule_id, DIAGNOSTIC_SOURCE_PATH_FIELD)
651 })?;
652 let property_path = property_path.ok_or_else(|| {
653 format!(
654 "Diagnostic '{}' on resource '{}' provides '{}' without a resource_path",
655 rule_id, resource_id, DIAGNOSTIC_SOURCE_PATH_FIELD
656 )
657 })?;
658 let effective_source_path = effective_path_from_scenario_source_path(source_path)
659 .ok_or_else(|| format!("Diagnostic '{}' has malformed scenario source path '{}'", rule_id, source_path))?;
660 if effective_source_path != property_path {
661 return Err(format!(
662 "Diagnostic '{}' source path '{}' resolves to '{}' instead of resource path '{}'",
663 rule_id, source_path, effective_source_path, property_path
664 ));
665 }
666 }
667
668 let location_path = source_path.or(property_path).unwrap_or("");
669 Ok(if let Some(resource_id) = resource_id {
670 model.resource_span(resource_id, location_path)
671 } else {
672 model.diagnostic_span(None, location_path).unwrap_or_else(|| resolve_section_span(rule_id, model))
673 })
674}
675
676fn validate_explicit_diagnostic_span(
677 diagnostic: &serde_json::Value,
678 rule_id: &str,
679 model_span: SourceSpan,
680) -> Result<(), String> {
681 let start_line = parse_span_coordinate(diagnostic, "start_line", rule_id)?;
682 let start_column = parse_span_coordinate(diagnostic, "start_column", rule_id)?;
683 let end_line = parse_span_coordinate(diagnostic, "end_line", rule_id)?;
684 let end_column = parse_span_coordinate(diagnostic, "end_column", rule_id)?;
685
686 if start_line.is_none() && start_column.is_none() && end_line.is_none() && end_column.is_none() {
687 return Ok(());
688 }
689 let (Some(start_line), Some(start_column)) = (start_line, start_column) else {
690 return Err(format!("Diagnostic '{}' explicit span must provide both start_line and start_column", rule_id));
691 };
692 let explicit_end = match (end_line, end_column) {
693 (None, None) => None,
694 (Some(line), Some(column)) => Some((line, column)),
695 _ => {
696 return Err(format!("Diagnostic '{}' explicit span must provide both end_line and end_column", rule_id));
697 }
698 };
699 if model_span == UNKNOWN_SPAN {
700 return Err(format!(
701 "Diagnostic '{}' explicit span cannot be verified because its resource path has no model-derived location",
702 rule_id
703 ));
704 }
705
706 let start_matches = start_line == model_span.start_line && start_column == model_span.start_column;
707 let end_matches =
708 explicit_end.is_none_or(|(line, column)| line == model_span.end_line && column == model_span.end_column);
709 if !start_matches || !end_matches {
710 let (explicit_end_line, explicit_end_column) = explicit_end.unwrap_or((start_line, start_column));
711 return Err(format!(
712 "Diagnostic '{}' explicit span {}:{}-{}:{} does not match model-derived span {}:{}-{}:{}",
713 rule_id,
714 start_line,
715 start_column,
716 explicit_end_line,
717 explicit_end_column,
718 model_span.start_line,
719 model_span.start_column,
720 model_span.end_line,
721 model_span.end_column
722 ));
723 }
724 Ok(())
725}
726
727pub(crate) fn parse_diagnostic(
728 val: &serde_json::Value,
729 model: &SemanticModel,
730 source_override: Option<&RuleOrigin>,
731) -> Result<Diagnostic, String> {
732 let rule_id =
733 val.get("rule_id").and_then(|v| v.as_str()).ok_or("Diagnostic missing required field 'rule_id'")?.to_string();
734 if rule_id.trim().is_empty() {
735 return Err("Diagnostic has an empty 'rule_id'".to_string());
736 }
737 let message = val
738 .get("message")
739 .and_then(|v| v.as_str())
740 .ok_or_else(|| format!("Diagnostic '{}' missing required field 'message'", rule_id))?
741 .to_string();
742 if message.trim().is_empty() {
743 return Err(format!("Diagnostic '{}' has an empty 'message'", rule_id));
744 }
745 let resource_id = val.get("resource_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
746 let entity = resource_id
747 .as_ref()
748 .and_then(|rid| Entity::resource(rid, model.resources.get(rid.as_str()).map(|r| r.resource_type.clone())));
749 let property_path =
750 val.get("resource_path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
751
752 let source_path = match val.get(DIAGNOSTIC_SOURCE_PATH_FIELD) {
753 None | Some(serde_json::Value::Null) => None,
754 Some(serde_json::Value::String(path)) if !path.is_empty() => Some(path.as_str()),
755 Some(_) => {
756 return Err(format!(
757 "Diagnostic '{}' field '{}' must be a non-empty string",
758 rule_id, DIAGNOSTIC_SOURCE_PATH_FIELD
759 ));
760 }
761 };
762 let span =
763 resolve_model_diagnostic_span(&rule_id, model, resource_id.as_deref(), property_path.as_deref(), source_path)?;
764 validate_explicit_diagnostic_span(val, &rule_id, span)?;
765
766 let is_custom_or_guard = source_override.is_some_and(|o| matches!(o, RuleOrigin::Custom | RuleOrigin::Guard));
767
768 let suggested_fix = val.get("suggested_fix").and_then(|v| v.as_str()).map(|s| s.to_string());
769 let documentation_url = val.get("documentation_url").and_then(|v| v.as_str()).map(|s| s.to_string());
770 let related_resources = val.get("related_locations").and_then(|v| v.as_array()).map(|arr| {
771 arr.iter()
772 .filter_map(|r| {
773 let rel_rid = r.get("resource_id").and_then(|v| v.as_str()).map(|s| s.to_string());
774 let rel_resource = rel_rid.as_ref().map(|rid| ResourceRef {
775 id: Some(rid.clone()),
776 resource_type: model.resources.get(rid.as_str()).map(|res| res.resource_type.clone()),
777 });
778 Some(RelatedResource {
779 resource: rel_resource,
780 location: Some(SourceSpan {
781 start_line: r.get("start_line")?.as_u64()? as u32,
782 start_column: r.get("start_column")?.as_u64()? as u32,
783 end_line: r.get("end_line")?.as_u64()? as u32,
784 end_column: r.get("end_column")?.as_u64()? as u32,
785 }),
786 message: r.get("message")?.as_str()?.to_string(),
787 })
788 })
789 .collect()
790 });
791 let condition_scenario = val
792 .get("condition_scenario")
793 .and_then(|v| v.as_object())
794 .map(|m| m.iter().filter_map(|(k, v)| v.as_bool().map(|b| (k.clone(), b))).collect());
795
796 if is_custom_or_guard {
797 if !is_valid_custom_rule_id(&rule_id) {
798 return Err(format!(
799 "Custom/Guard diagnostic '{}' has an invalid 'rule_id': only letters, digits, and the separators \
800 '_', '.', '-' are allowed",
801 rule_id
802 ));
803 }
804 let severity_str = val
805 .get("severity")
806 .and_then(|v| v.as_str())
807 .ok_or_else(|| format!("Custom/Guard diagnostic '{}' missing required field 'severity'", rule_id))?;
808 let severity = severity_str.parse::<Severity>()?;
811 let category = val.get("category").and_then(|v| v.as_str()).map(|c| c.to_string());
812 let source = *source_override.expect("custom/guard branch is only reached with a source override");
813 let rule_description = (source == RuleOrigin::Custom).then(|| message.clone());
814 return Ok(Diagnostic {
815 rule_id,
816 severity,
817 message,
818 entity,
819 property_path,
820 suggested_fix,
821 documentation_url,
822 category,
823 location: span_to_option(span),
824 related_resources,
825 condition_scenario,
826 rule_description,
827 phase: None,
828 context: None,
829 source,
830 });
831 }
832
833 let mut diagnostic = RegisteredDiagnostic::new(rule_id, message)
837 .location(span)
838 .suggested_fix(suggested_fix)
839 .condition_scenario(condition_scenario)
840 .related_resources(related_resources)
841 .build();
842 diagnostic.entity = entity;
843 diagnostic.property_path = property_path;
844 diagnostic.documentation_url = documentation_url;
845 Ok(diagnostic)
846}
847
848pub fn extract_diagnostics(
853 json_str: &str,
854 model: &SemanticModel,
855 out: &mut Vec<Diagnostic>,
856 source_override: Option<&RuleOrigin>,
857) -> Result<(), String> {
858 let json_val: serde_json::Value =
859 serde_json::from_str(json_str).map_err(|e| format!("Failed to parse diagnostic JSON: {}", e))?;
860 extract_diagnostics_from_value(&json_val, model, out, source_override)
861}
862
863#[doc(hidden)]
867pub fn extract_diagnostics_from_value(
868 json_val: &serde_json::Value,
869 model: &SemanticModel,
870 out: &mut Vec<Diagnostic>,
871 source_override: Option<&RuleOrigin>,
872) -> Result<(), String> {
873 let items = json_val.as_array().ok_or("Diagnostic output must be a JSON array")?;
874 for item in items {
875 out.push(parse_diagnostic(item, model, source_override)?);
876 }
877 Ok(())
878}
879
880pub(crate) fn build_report(
881 diagnostics: Vec<Diagnostic>,
882 model: &SemanticModel,
883 suppressed: u32,
884 rules_evaluated: Option<u32>,
885 strict: bool,
886 severity_level: Severity,
887 file_path: String,
888) -> ValidationReport {
889 build_report_with_versions(
890 diagnostics,
891 model,
892 suppressed,
893 rules_evaluated,
894 strict,
895 severity_level,
896 file_path,
897 DATA_SOURCE_VERSIONS.cfn_lint_version.to_owned(),
898 DATA_SOURCE_VERSIONS.resource_schema_version.to_owned(),
899 )
900}
901
902fn build_report_with_versions(
903 diagnostics: Vec<Diagnostic>,
904 model: &SemanticModel,
905 suppressed: u32,
906 rules_evaluated: Option<u32>,
907 strict: bool,
908 severity_level: Severity,
909 file_path: String,
910 cfn_lint_version: String,
911 resource_schema_version: String,
912) -> ValidationReport {
913 let fatal = diagnostics.iter().filter(|d| d.severity == Severity::Fatal).count() as u32;
914 let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count() as u32;
915 let warnings = diagnostics.iter().filter(|d| d.severity == Severity::Warn).count() as u32;
916 let debug = diagnostics.iter().filter(|d| d.severity == Severity::Debug).count() as u32;
917 let informational = diagnostics.len() as u32 - fatal - errors - warnings - debug;
918 ValidationReport {
919 file_path,
920 status: ReportStatus::Ok,
921 version: env!("CARGO_PKG_VERSION").to_string(),
922 diagnostics,
923 metadata: ReportMetadata {
924 rules_evaluated: rules_evaluated.unwrap_or(0),
925 cfn_lint_version,
926 resource_schema_version,
927 resources_scanned: model.resources.len() as u32,
928 counts: Summary { fatal, errors, warnings, informational, debug },
929 suppressed,
930 strict,
931 severity_level,
932 budget_exhaustions: None,
933 },
934 performance: PerformanceMetrics {
935 schema_init: PhaseMetric { duration_ms: 0.0 },
936 engine_init: PhaseMetric { duration_ms: 0.0 },
937 model_build: PhaseMetric { duration_ms: 0.0 },
938 schema_validate: PhaseMetric { duration_ms: 0.0 },
939 rule_evaluation: PhaseMetric { duration_ms: 0.0 },
940 diagnostic_finalize: PhaseMetric { duration_ms: 0.0 },
941 validate_total: PhaseMetric { duration_ms: 0.0 },
942 },
943 }
944}
945
946pub(crate) fn finalize_diagnostics(diagnostics: &mut Vec<Diagnostic>, config: &ValidateConfig) -> (u32, u32) {
947 let total_before = diagnostics.len() as u32;
948
949 if config.disable_builtin_rules {
950 diagnostics.retain(|d| matches!(d.source, RuleOrigin::Custom | RuleOrigin::Guard));
951 }
952
953 if config.strict {
954 for d in diagnostics.iter_mut() {
955 if d.severity == Severity::Warn {
956 d.severity = Severity::Error;
957 }
958 }
959 }
960
961 apply_filters(diagnostics, &config.filters);
962
963 diagnostics.retain(|d| d.severity >= config.severity_level);
965
966 let line = |d: &Diagnostic| d.location.as_ref().map(|l| l.start_line).unwrap_or(0);
979 let column = |d: &Diagnostic| d.location.as_ref().map(|l| l.start_column).unwrap_or(0);
980 fn resource_id(d: &Diagnostic) -> &str {
988 d.entity.as_ref().map(|e| e.logical_id.as_str()).unwrap_or_default()
989 }
990 diagnostics.sort_by(|a, b| {
991 b.severity
992 .cmp(&a.severity)
993 .then_with(|| rule_number(&a.rule_id).cmp(&rule_number(&b.rule_id)))
994 .then_with(|| a.rule_id.cmp(&b.rule_id))
995 .then_with(|| line(a).cmp(&line(b)))
996 .then_with(|| column(a).cmp(&column(b)))
997 .then_with(|| resource_id(a).cmp(resource_id(b)))
998 .then_with(|| a.property_path.cmp(&b.property_path))
999 .then_with(|| a.message.cmp(&b.message))
1000 });
1001 diagnostics.dedup_by(|a, b| {
1002 a.location.as_ref().map(|l| l.start_line).unwrap_or(0) == b.location.as_ref().map(|l| l.start_line).unwrap_or(0)
1003 && a.location.as_ref().map(|l| l.start_column).unwrap_or(0)
1004 == b.location.as_ref().map(|l| l.start_column).unwrap_or(0)
1005 && a.rule_id == b.rule_id
1006 && resource_id(a) == resource_id(b)
1007 && a.message == b.message
1008 && a.property_path == b.property_path
1009 });
1010
1011 let suppressed = total_before.saturating_sub(diagnostics.len() as u32);
1012 (total_before, suppressed)
1013}
1014
1015pub(crate) fn build_context(
1016 rule_id: &str,
1017 resource_id: Option<&str>,
1018 property_path: &str,
1019 model: &SemanticModel,
1020) -> Option<ViolationContext> {
1021 let rid = resource_id?;
1022
1023 let resolve_val = |path: &str| -> Option<serde_json::Value> {
1024 let scenarios = model.resolve_scenarios_json(rid, path);
1025 scenarios.into_iter().next().map(|(v, _)| v)
1026 };
1027
1028 let unambiguous_val = |path: &str| -> Option<serde_json::Value> {
1035 let mut scenarios = model.resolve_scenarios_json(rid, path).into_iter().map(|(v, _)| v);
1036 let first = scenarios.next()?;
1037 if scenarios.all(|v| v == first) { Some(first) } else { None }
1038 };
1039
1040 let mut actual_value: Option<JsonValue> = None;
1041 let mut property = None;
1042 let mut lifecycle = None;
1043 let mut extra: HashMap<String, JsonValue> = HashMap::new();
1044
1045 match rule_id {
1046 "F3012" | "E3012" => {
1047 if !property_path.is_empty() {
1048 actual_value = resolve_val(property_path).map(Into::into);
1049 }
1050 }
1051 "F3030" | "W3030" | "E3030" | "F3031" | "E3031" | "F3034" | "E3034" | "F3037" | "W3045" | "E1103" | "E1150"
1052 | "E1151" | "E1152" | "E1153" | "E1154" | "E1155" | "E1156" => {
1053 actual_value = resolve_val(property_path).map(Into::into);
1054 }
1055 "F3033" | "W9006" => {
1056 if let Some(v) = resolve_val(property_path) {
1057 if let Some(s) = v.as_str() {
1058 extra.insert("actual_length".into(), serde_json::json!(s.chars().count()).into());
1059 }
1060 actual_value = Some(v.into());
1061 }
1062 }
1063 "F3032" | "E3032" => {
1064 if let Some(v) = resolve_val(property_path)
1065 && let Some(arr) = v.as_array()
1066 {
1067 extra.insert("actual_count".into(), serde_json::json!(arr.len()).into());
1068 }
1069 }
1070 "F3002" | "E3002" | "F3020" => {
1071 let prop = property_path.rsplit('.').next().unwrap_or("");
1072 if !prop.is_empty() {
1073 property = Some(prop.to_string());
1074 }
1075 }
1076 "F3003" | "F3021" | "F3014" | "F3058" => {}
1077 "E3047" => {
1078 if let Some(v) = resolve_val("Properties.Cpu") {
1079 extra.insert("cpu".into(), v.into());
1080 }
1081 if let Some(v) = resolve_val("Properties.Memory") {
1082 extra.insert("memory".into(), v.into());
1083 }
1084 }
1085 "E3060" => {
1086 if let Some(v) = resolve_val("Properties.CidrBlock") {
1087 extra.insert("cidr".into(), v.into());
1088 }
1089 }
1090 "E9002" => {
1091 actual_value = resolve_val("Properties.SecurityGroupIngress").map(Into::into);
1092 }
1093 "F3006" => {
1094 if let Some(res) = model.resources.get(rid) {
1095 extra.insert("resource_type".into(), serde_json::json!(res.resource_type).into());
1096 }
1097 }
1098 "E3501" => {
1099 if let Some(v) = resolve_val("Properties.QueueName") {
1100 extra.insert("queue_name".into(), v.into());
1101 }
1102 }
1103 "E3505" => {
1104 if let Some(v) = resolve_val("Properties.VisibilityTimeout") {
1105 extra.insert("visibility_timeout".into(), v.into());
1106 }
1107 }
1108 "E3044" => {
1109 if let Some(v) = resolve_val("Properties.LaunchType") {
1110 extra.insert("launch_type".into(), v.into());
1111 }
1112 if let Some(v) = resolve_val("Properties.SchedulingStrategy") {
1113 extra.insert("scheduling_strategy".into(), v.into());
1114 }
1115 }
1116 "E3053" => {
1117 if !property_path.is_empty() {
1118 actual_value = resolve_val(property_path).map(Into::into);
1119 }
1120 }
1121 "W9009" => {
1122 lifecycle = Some("deprecated".into());
1123 }
1124 "I9001" => {
1125 lifecycle = Some("create-only".into());
1126 }
1127 "W9054" => {
1128 lifecycle = Some("write-only".into());
1129 }
1130 "E2533" => {
1136 actual_value = unambiguous_val(property_path).map(Into::into);
1137 lifecycle = Some("end-of-life".into());
1138 }
1139 "E2531" => {
1140 actual_value = unambiguous_val(property_path).map(Into::into);
1141 lifecycle = Some("create-blocked".into());
1142 }
1143 "W2531" => {
1144 actual_value = unambiguous_val(property_path).map(Into::into);
1145 lifecycle = Some("deprecated".into());
1146 }
1147 "W9011" => {
1150 actual_value = unambiguous_val(property_path).map(Into::into);
1151 }
1152 "W2503" | "W2502" => {
1153 if let Some(res) = model.resources.get(rid)
1154 && let Some(ref c) = res.condition
1155 {
1156 extra.insert("source_condition".into(), serde_json::json!(c).into());
1157 }
1158 }
1159 _ => {}
1160 }
1161
1162 if actual_value.is_none() && property.is_none() && lifecycle.is_none() && extra.is_empty() {
1163 return None;
1164 }
1165
1166 Some(ViolationContext {
1167 actual_value,
1168 expected_constraint: None,
1169 property,
1170 lifecycle,
1171 resolution_source: None,
1172 extra: if extra.is_empty() { None } else { Some(extra) },
1173 })
1174}
1175
1176fn gate_sam_transform_errors(diagnostics: &mut Vec<Diagnostic>) {
1182 let has_transform_error = diagnostics.iter().any(|d| is_sam_transform_error_message(&d.message));
1183 if has_transform_error {
1184 diagnostics.retain(|d| is_sam_transform_error_message(&d.message));
1185 }
1186}
1187
1188const CDK_SUPPRESSED_RULE_IDS: [&str; 2] = ["I1022", "W3010"];
1192
1193fn gate_cdk_suppressed_rules(diagnostics: &mut Vec<Diagnostic>, model: &SemanticModel) {
1196 if !model.is_cdk {
1197 return;
1198 }
1199 diagnostics.retain(|d| !CDK_SUPPRESSED_RULE_IDS.contains(&d.rule_id.as_str()));
1200}
1201
1202fn backfill_locations(diagnostics: &mut [Diagnostic], model: &SemanticModel) {
1216 for d in diagnostics.iter_mut() {
1217 if d.location.is_some() {
1218 continue;
1219 }
1220 let resource_id = d.resource_logical_id();
1221 let property_path = d.property_path.as_deref().unwrap_or("");
1222 let span = model
1223 .diagnostic_span(resource_id, property_path)
1224 .unwrap_or_else(|| resolve_section_span(&d.rule_id, model));
1225 d.location = span_to_option(span);
1226 }
1227}
1228
1229fn backfill_entities(diagnostics: &mut [Diagnostic], model: &SemanticModel) {
1237 for d in diagnostics.iter_mut() {
1238 if d.entity.is_none() {
1239 d.entity = d.property_path.as_deref().and_then(entity_identity).map(|(entity_type, logical_id)| Entity {
1240 logical_id: logical_id.to_string(),
1241 entity_type,
1242 resource_type: None,
1243 });
1244 }
1245 if let Some(entity) = d.entity.as_mut()
1246 && entity.entity_type == EntityType::Resource
1247 && entity.resource_type.is_none()
1248 {
1249 entity.resource_type = model.resources.get(entity.logical_id.as_str()).map(|r| r.resource_type.clone());
1250 }
1251 }
1252}
1253
1254pub(crate) fn enrich_diagnostics(
1255 diagnostics: &mut [Diagnostic],
1256 model: &SemanticModel,
1257 registry_metadata: &HashMap<String, RuleMetadataEntry>,
1258 external_metadata: &HashMap<String, RuleMetadataEntry>,
1259 format: &DetailLevel,
1260) {
1261 if !format.needs_enrichment() {
1262 return;
1263 }
1264 let needs_context = format.needs_context();
1265
1266 for d in diagnostics.iter_mut() {
1267 if d.phase.is_none() && !matches!(d.source, RuleOrigin::Custom | RuleOrigin::Guard) {
1271 d.phase = Some(if is_fatal_rule(&d.rule_id) { Phase::Schema } else { Phase::Lint });
1272 }
1273 if d.rule_description.is_none() {
1274 d.rule_description = registry_metadata
1275 .get(&d.rule_id)
1276 .or_else(|| external_metadata.get(&d.rule_id))
1277 .map(|entry| entry.description.clone());
1278 }
1279 if needs_context && d.context.is_none() {
1280 d.context =
1281 build_context(&d.rule_id, d.resource_logical_id(), d.property_path.as_deref().unwrap_or(""), model);
1282 }
1283 }
1284}
1285
1286pub fn build_rule_list(
1287 registry: &HashMap<String, RuleMetadataEntry>,
1288 external: &HashMap<String, RuleMetadataEntry>,
1289) -> Vec<RuleInfo> {
1290 let mut rules: Vec<RuleInfo> = registry
1291 .iter()
1292 .chain(external.iter())
1293 .map(|(id, entry)| RuleInfo {
1294 id: id.clone(),
1295 severity: entry.severity,
1296 category: entry.category.clone(),
1297 description: entry.description.clone(),
1298 origin: entry.origin,
1299 })
1300 .collect();
1301 rules.sort_by(|a, b| a.id.cmp(&b.id));
1302 rules
1303}
1304
1305pub fn make_resource_diagnostic(
1306 rule_id: &str,
1307 message: &str,
1308 model: &SemanticModel,
1309 resource_id: &str,
1310 prop_path: &str,
1311 suggested_fix: Option<&str>,
1312) -> Diagnostic {
1313 make_resource_diagnostic_at_source(rule_id, message, model, resource_id, prop_path, prop_path, suggested_fix)
1314}
1315
1316pub fn make_resource_diagnostic_at_source(
1319 rule_id: &str,
1320 message: &str,
1321 model: &SemanticModel,
1322 resource_id: &str,
1323 prop_path: &str,
1324 source_path: &str,
1325 suggested_fix: Option<&str>,
1326) -> Diagnostic {
1327 let span = if resource_id.is_empty() {
1331 match model.diagnostic_span(None, source_path) {
1332 Some(found) => found,
1333 None => resolve_section_span(rule_id, model),
1334 }
1335 } else {
1336 model.resource_span(resource_id, source_path)
1337 };
1338 RegisteredDiagnostic::new(rule_id, message)
1339 .property_path(prop_path)
1340 .location(span)
1341 .suggested_fix(suggested_fix)
1342 .resource(resource_id, model.resources.get(resource_id).map(|r| r.resource_type.clone()))
1343 .build()
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 use super::*;
1349 use diagnostics::Phase;
1350 use rules::{Category, RuleFilterConfig, build_rule_metadata_map, lookup_rule};
1351 use std::sync::atomic::{AtomicUsize, Ordering};
1352 use template_model::{SAM_TRANSFORM_ERROR_PREFIX, SAM_TRANSFORM_ERROR_RULE_ID};
1353
1354 const TEST_CFN_LINT_VERSION: &str = "https://github.com/aws-cloudformation/cfn-lint@1.54.0";
1355 const TEST_RESOURCE_SCHEMA_VERSION: &str =
1356 "https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z";
1357 #[test]
1358 fn additional_schema_resolve_uses_explicit_type_name() {
1359 let src = AdditionalSchemaSource {
1360 type_name: Some("AWS::Lambda::Function".into()),
1361 schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
1362 };
1363 let (type_name, schema) = src.resolve().expect("valid schema resolves");
1364 assert_eq!(type_name, "AWS::Lambda::Function", "an explicit type_name names the target type");
1365 assert!(schema.is_object());
1366 }
1367
1368 #[test]
1369 fn additional_schema_resolve_accepts_matching_type_names() {
1370 let src = AdditionalSchemaSource {
1371 type_name: Some("AWS::Lambda::Function".into()),
1372 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"P":{"type":"string"}}}"#.into(),
1373 };
1374 let (type_name, _) = src.resolve().expect("agreeing type names resolve");
1375 assert_eq!(type_name, "AWS::Lambda::Function");
1376 }
1377
1378 #[test]
1379 fn additional_schema_resolve_rejects_contradictory_type_names() {
1380 let src = AdditionalSchemaSource {
1381 type_name: Some("AWS::Lambda::Function".into()),
1382 schema: r#"{"typeName":"AWS::Other::Type","properties":{}}"#.into(),
1383 };
1384 let err = src.resolve().expect_err("contradictory type names must fail");
1385 assert!(
1386 err.0.contains("AWS::Lambda::Function") && err.0.contains("AWS::Other::Type"),
1387 "the error must name both types, got: {}",
1388 err.0
1389 );
1390 }
1391
1392 #[test]
1393 fn additional_schema_resolve_falls_back_to_schema_type_name() {
1394 let src = AdditionalSchemaSource {
1395 type_name: None,
1396 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{}}"#.into(),
1397 };
1398 let (type_name, _) = src.resolve().expect("valid schema resolves");
1399 assert_eq!(type_name, "AWS::Lambda::Function", "an absent type_name falls back to the schema's typeName");
1400 }
1401
1402 #[test]
1403 fn additional_schema_resolve_rejects_invalid_json() {
1404 let src =
1405 AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{ not json ".into() };
1406 let err = src.resolve().expect_err("invalid JSON must fail");
1407 assert!(err.0.contains("Invalid additional schema"), "unexpected error: {}", err.0);
1408 }
1409
1410 #[test]
1411 fn additional_schema_resolve_rejects_non_object() {
1412 let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "42".into() };
1413 let err = src.resolve().expect_err("non-object schema must fail");
1414 assert!(err.0.contains("expected a JSON object"), "unexpected error: {}", err.0);
1415 }
1416
1417 #[test]
1418 fn additional_schema_resolve_rejects_missing_type_name() {
1419 let src = AdditionalSchemaSource { type_name: None, schema: r#"{"properties":{}}"#.into() };
1420 let err = src.resolve().expect_err("missing type name must fail");
1421 assert!(err.0.contains("missing a resource type name"), "unexpected error: {}", err.0);
1422 }
1423
1424 #[test]
1425 fn additional_schema_resolve_error_names_an_unnamed_source() {
1426 let src = AdditionalSchemaSource { type_name: None, schema: "{ not json ".into() };
1427 let err = src.resolve().expect_err("invalid JSON must fail");
1428 assert!(err.0.contains("<unnamed>"), "unexpected error: {}", err.0);
1429 }
1430
1431 #[test]
1432 fn engine_config_build_overlay_catalog_applies_schemas() {
1433 let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
1434 additional_schemas: vec![AdditionalSchemaSource {
1435 type_name: None,
1436 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
1437 .into(),
1438 }],
1439 });
1440 let catalog = config.build_overlay_catalog().expect("valid overlay builds catalog");
1441 assert!(!catalog.is_empty());
1442 assert!(catalog.type_names.contains(&"AWS::Lambda::Function".to_string()));
1443 }
1444
1445 #[test]
1446 fn engine_config_build_overlay_catalog_empty_is_noop() {
1447 let config = EngineConfig::default();
1448 let catalog = config.build_overlay_catalog().expect("empty config builds empty catalog");
1449 assert!(catalog.is_empty());
1450 }
1451
1452 #[test]
1453 fn engine_config_builder_composes_without_a_struct_literal() {
1454 let config = EngineConfig::new()
1455 .with_custom_rules([ExternalRuleSource { name: "a.rego".into(), content: "package a".into() }])
1456 .with_guard_rules([ExternalRuleSource { name: "b.guard".into(), content: "rule x {}".into() }])
1457 .with_schema_validator_config(SchemaValidatorConfig {
1458 additional_schemas: vec![AdditionalSchemaSource {
1459 type_name: Some("AWS::Test::One".into()),
1460 schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
1461 }],
1462 });
1463 assert_eq!(config.custom_rules.len(), 1);
1464 assert_eq!(config.guard_rules.len(), 1);
1465 let sv_config = config.schema_validator_config.as_ref().expect("schema_validator config must be set");
1466 assert_eq!(sv_config.additional_schemas.len(), 1);
1467 assert_eq!(sv_config.additional_schemas[0].type_name.as_deref(), Some("AWS::Test::One"));
1468 }
1469
1470 #[test]
1471 fn default_engine_config_serializes_without_the_schema_validator_config_field() {
1472 let json = serde_json::to_value(EngineConfig::default()).expect("EngineConfig serializes");
1473 assert!(
1474 json.get("schemaValidatorConfig").is_none(),
1475 "an empty schema_validator_config must not change the serialized form, got: {json}"
1476 );
1477 }
1478
1479 #[test]
1480 fn engine_config_nested_schema_validator_serialization_roundtrip() {
1481 let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
1482 additional_schemas: vec![AdditionalSchemaSource {
1483 type_name: Some("AWS::Test::RT".into()),
1484 schema: r#"{"properties":{}}"#.into(),
1485 }],
1486 });
1487 let json = serde_json::to_value(&config).expect("serializes");
1488 assert!(json.get("schemaValidatorConfig").is_some(), "nested config must appear in JSON: {json}");
1489 assert!(json.get("schemaValidator").is_none(), "the old config field must not be serialized: {json}");
1490 let deserialized: EngineConfig = serde_json::from_value(json).expect("deserializes");
1491 let sv = deserialized.schema_validator_config.expect("nested config must roundtrip");
1492 assert_eq!(sv.additional_schemas.len(), 1);
1493 assert_eq!(sv.additional_schemas[0].type_name.as_deref(), Some("AWS::Test::RT"));
1494 }
1495
1496 #[test]
1497 fn standalone_engine_with_nested_schema_config_derives_metadata() {
1498 let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
1502 additional_schemas: vec![AdditionalSchemaSource {
1503 type_name: None,
1504 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
1505 .into(),
1506 }],
1507 });
1508 let catalog = config.build_overlay_catalog().expect("nested config builds");
1510 assert!(!catalog.is_empty(), "the catalog must be populated from nested config");
1511 }
1512
1513 #[test]
1514 fn combined_wrapper_style_path_constructs_validator_and_engine_once() {
1515 let schema_config = SchemaValidatorConfig {
1519 additional_schemas: vec![AdditionalSchemaSource {
1520 type_name: None,
1521 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
1522 .into(),
1523 }],
1524 };
1525 let validator = schema_validator::SchemaValidator::new(schema_config).expect("validator builds");
1526 assert!(!validator.overlay_catalog().is_empty());
1528 assert!(validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()));
1529 }
1530
1531 #[test]
1532 fn standalone_schema_validator_config() {
1533 let config = SchemaValidatorConfig {
1535 additional_schemas: vec![AdditionalSchemaSource {
1536 type_name: None,
1537 schema: r#"{"typeName":"AWS::Test::Standalone","properties":{"Name":{"type":"string"}}}"#.into(),
1538 }],
1539 };
1540 let validator = schema_validator::SchemaValidator::new(config).expect("standalone validator builds");
1541 assert!(validator.overlay_catalog().type_names.contains(&"AWS::Test::Standalone".to_string()));
1542 }
1543
1544 fn minimal_model() -> SemanticModel {
1545 let yaml = br#"
1546AWSTemplateFormatVersion: "2010-09-09"
1547Resources:
1548 Bucket:
1549 Type: AWS::S3::Bucket
1550 Properties:
1551 BucketName: my-bucket
1552"#;
1553 SemanticModel::from_bytes(yaml).expect("minimal model should parse")
1554 }
1555
1556 fn meta_map() -> HashMap<String, RuleMetadataEntry> {
1557 build_rule_metadata_map()
1558 }
1559
1560 fn default_diag() -> Diagnostic {
1561 Diagnostic {
1562 rule_id: String::new(),
1563 severity: Severity::Info,
1564 message: String::new(),
1565 entity: None,
1566 property_path: None,
1567 suggested_fix: None,
1568 documentation_url: None,
1569 category: None,
1570 location: None,
1571 related_resources: None,
1572 condition_scenario: None,
1573 rule_description: None,
1574 phase: None,
1575 context: None,
1576 source: RuleOrigin::Engine,
1577 }
1578 }
1579
1580 #[test]
1581 fn parse_diagnostic_minimal_valid() {
1582 let model = minimal_model();
1583 let val: serde_json::Value = serde_json::json!({
1584 "rule_id": "E3012",
1585 "severity": Severity::Error.as_str(),
1586 "message": "Type mismatch",
1587 "resource_id": "Bucket",
1588 "resource_path": "Properties.BucketName"
1589 });
1590 let diag = parse_diagnostic(&val, &model, None).expect("should parse");
1591 assert_eq!(diag.rule_id, "E3012");
1592 assert_eq!(diag.severity, Severity::Error);
1593 assert_eq!(diag.message, "Type mismatch");
1594 assert_eq!(diag.resource_logical_id(), Some("Bucket"));
1595 assert_eq!(diag.entity.as_ref().and_then(|e| e.resource_type.as_deref()), Some("AWS::S3::Bucket"));
1596 }
1597
1598 #[test]
1599 fn parse_diagnostic_missing_rule_id_returns_error() {
1600 let model = minimal_model();
1601 let val = serde_json::json!({"severity": Severity::Error.as_str(), "message": "x"});
1602 parse_diagnostic(&val, &model, None).unwrap_err();
1603 }
1604
1605 #[test]
1606 fn parse_diagnostic_missing_severity_for_builtin_is_accepted() {
1607 let model = minimal_model();
1608 let val = serde_json::json!({"rule_id": "E3012", "message": "x"});
1609 let diag = parse_diagnostic(&val, &model, None).expect("built-in diagnostic must not require severity in JSON");
1610 assert_eq!(diag.severity, Severity::Error, "registry-derived severity must win for built-in rules");
1611 }
1612
1613 #[test]
1614 fn parse_diagnostic_missing_severity_for_custom_returns_error() {
1615 let model = minimal_model();
1616 let val = serde_json::json!({"rule_id": "XCUSTOM", "message": "x"});
1617 parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
1618 .expect_err("custom/guard diagnostics without severity must error");
1619 }
1620
1621 #[test]
1622 fn parse_diagnostic_builtin_ignores_json_severity() {
1623 let model = minimal_model();
1624 let val = serde_json::json!({
1625 "rule_id": "E3012",
1626 "severity": Severity::Warn.as_str(),
1627 "message": "x"
1628 });
1629 let diag = parse_diagnostic(&val, &model, None).unwrap();
1630 assert_eq!(
1631 diag.severity,
1632 Severity::Error,
1633 "the registry's Error severity for E3012 must override any JSON severity"
1634 );
1635 }
1636
1637 #[test]
1638 fn parse_diagnostic_missing_message_returns_error() {
1639 let model = minimal_model();
1640 let val = serde_json::json!({"rule_id": "E3012", "severity": Severity::Error.as_str()});
1641 parse_diagnostic(&val, &model, None).unwrap_err();
1642 }
1643
1644 #[test]
1645 fn parse_diagnostic_empty_rule_id_returns_error() {
1646 let model = minimal_model();
1647 let val = serde_json::json!({"rule_id": "", "severity": Severity::Error.as_str(), "message": "x"});
1648 let err = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
1649 .expect_err("an empty rule_id must be rejected, not accepted as a blank-ID diagnostic");
1650 assert!(err.contains("empty 'rule_id'"), "got: {err}");
1651 }
1652
1653 #[test]
1654 fn parse_diagnostic_empty_message_returns_error() {
1655 let model = minimal_model();
1656 let val = serde_json::json!({"rule_id": "CUSTOM1", "severity": Severity::Error.as_str(), "message": " "});
1657 let err =
1658 parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).expect_err("a blank message must be rejected");
1659 assert!(err.contains("CUSTOM1") && err.contains("empty 'message'"), "got: {err}");
1660 }
1661
1662 #[test]
1663 fn parse_diagnostic_custom_severity_is_authoritative_even_for_f_prefixed_id() {
1664 let model = minimal_model();
1669 let val = serde_json::json!({
1670 "rule_id": "FCUSTOM",
1671 "severity": Severity::Error.as_str(),
1672 "message": "type mismatch"
1673 });
1674 let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
1675 assert_eq!(diag.severity, Severity::Error, "declared severity must survive an F-prefixed custom ID");
1676 }
1677
1678 #[test]
1679 fn parse_diagnostic_custom_rule_id_allows_separators_but_rejects_punctuation() {
1680 let model = minimal_model();
1681 let ok = serde_json::json!({
1682 "rule_id": "s3.encryption-required_1",
1683 "severity": Severity::Warn.as_str(),
1684 "message": "ok"
1685 });
1686 assert!(parse_diagnostic(&ok, &model, Some(&RuleOrigin::Custom)).is_ok());
1687
1688 let bad = serde_json::json!({
1689 "rule_id": "bad id/with space",
1690 "severity": Severity::Warn.as_str(),
1691 "message": "bad"
1692 });
1693 let err = parse_diagnostic(&bad, &model, Some(&RuleOrigin::Custom))
1694 .expect_err("a rule_id with whitespace/punctuation must be rejected");
1695 assert!(err.contains("invalid 'rule_id'"), "got: {err}");
1696 }
1697
1698 #[test]
1699 fn parse_diagnostic_warning_severity() {
1700 let model = minimal_model();
1701 let val = serde_json::json!({
1702 "rule_id": "W3045",
1703 "severity": Severity::Warn.as_str(),
1704 "message": "warn"
1705 });
1706 let diag = parse_diagnostic(&val, &model, None).unwrap();
1707 assert_eq!(diag.severity, Severity::Warn);
1708 }
1709
1710 #[test]
1711 fn parse_diagnostic_with_suggested_fix_and_doc_url() {
1712 let model = minimal_model();
1713 let val = serde_json::json!({
1714 "rule_id": "E3012",
1715 "severity": Severity::Error.as_str(),
1716 "message": "bad",
1717 "suggested_fix": "fix it",
1718 "documentation_url": "https://example.com"
1719 });
1720 let diag = parse_diagnostic(&val, &model, None).unwrap();
1721 assert_eq!(diag.suggested_fix.as_deref(), Some("fix it"));
1722 assert_eq!(diag.documentation_url.as_deref(), Some("https://example.com"));
1723 }
1724
1725 #[test]
1726 fn parse_diagnostic_empty_resource_id_treated_as_none() {
1727 let model = minimal_model();
1728 let val = serde_json::json!({
1729 "rule_id": "E3012",
1730 "severity": Severity::Error.as_str(),
1731 "message": "x",
1732 "resource_id": ""
1733 });
1734 let diag = parse_diagnostic(&val, &model, None).unwrap();
1735 assert!(diag.entity.is_none(), "diagnostic without resource_id should have no entity");
1736 }
1737
1738 #[test]
1739 fn parse_diagnostic_accepts_model_matching_explicit_location() {
1740 let model = minimal_model();
1741 let expected = model.resource_span("Bucket", "Properties.BucketName");
1742 let val = serde_json::json!({
1743 "rule_id": "E3012",
1744 "severity": Severity::Error.as_str(),
1745 "message": "x",
1746 "resource_id": "Bucket",
1747 "resource_path": "Properties.BucketName",
1748 "start_line": expected.start_line,
1749 "start_column": expected.start_column,
1750 "end_line": expected.end_line,
1751 "end_column": expected.end_column
1752 });
1753 let diag = parse_diagnostic(&val, &model, None).expect("model-matching coordinates should be accepted");
1754 assert_eq!(diag.location, Some(expected));
1755 }
1756
1757 #[test]
1758 fn parse_diagnostic_rejects_unverified_explicit_location() {
1759 let model = minimal_model();
1760 let val = serde_json::json!({
1761 "rule_id": "XCUSTOM",
1762 "severity": Severity::Error.as_str(),
1763 "message": "x",
1764 "resource_id": "Bucket",
1765 "resource_path": "Properties.BucketName",
1766 "start_line": 42,
1767 "start_column": 7,
1768 "end_line": 43,
1769 "end_column": 9
1770 });
1771 let error = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
1772 .expect_err("custom coordinates outside the declared property must be rejected");
1773 assert!(error.contains("does not match model-derived span"), "got: {error}");
1774 }
1775
1776 #[test]
1777 fn parse_diagnostic_accepts_verified_conditional_source_path() {
1778 let model = SemanticModel::from_bytes(
1779 br#"
1780Parameters:
1781 Mode: {Type: String}
1782Conditions:
1783 UseFirst: !Equals [!Ref Mode, first]
1784Resources:
1785 Record:
1786 Type: Custom::Record
1787 Properties:
1788 Values: !If
1789 - UseFirst
1790 - [invalid]
1791 - [valid]
1792"#,
1793 )
1794 .expect("conditional model should parse");
1795 let source_path = "Properties.Values.Fn::If.1.0";
1796 let expected = model.resource_span("Record", source_path);
1797 let val = serde_json::json!({
1798 "rule_id": "E3012",
1799 "severity": Severity::Error.as_str(),
1800 "message": "x",
1801 "resource_id": "Record",
1802 "resource_path": "Properties.Values.0",
1803 (DIAGNOSTIC_SOURCE_PATH_FIELD): source_path,
1804 "start_line": expected.start_line,
1805 "start_column": expected.start_column,
1806 "end_line": expected.end_line,
1807 "end_column": expected.end_column
1808 });
1809 let diag = parse_diagnostic(&val, &model, None).expect("verified conditional source path should be accepted");
1810 assert_eq!(diag.location, Some(expected));
1811 }
1812
1813 #[test]
1814 fn parse_diagnostic_rejects_source_path_for_another_property() {
1815 let model = minimal_model();
1816 let expected = model.resource_span("Bucket", "Properties.BucketName");
1817 let val = serde_json::json!({
1818 "rule_id": "E3012",
1819 "severity": Severity::Error.as_str(),
1820 "message": "x",
1821 "resource_id": "Bucket",
1822 "resource_path": "Properties.BucketName",
1823 (DIAGNOSTIC_SOURCE_PATH_FIELD): "Properties.Tags.Fn::If.1.0",
1824 "start_line": expected.start_line,
1825 "start_column": expected.start_column,
1826 "end_line": expected.end_line,
1827 "end_column": expected.end_column
1828 });
1829 let error = parse_diagnostic(&val, &model, None)
1830 .expect_err("a source path for another property must not relocate the diagnostic");
1831 assert!(error.contains("instead of resource path 'Properties.BucketName'"), "got: {error}");
1832 }
1833
1834 #[test]
1835 fn parse_diagnostic_unknown_rule_uses_json_category() {
1836 let model = minimal_model();
1837 let val = serde_json::json!({
1838 "rule_id": "XUNKNOWN",
1839 "severity": Severity::Error.as_str(),
1840 "message": "x",
1841 "category": "my-category"
1842 });
1843 let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
1844 assert_eq!(diag.category.as_deref(), Some("my-category"));
1845 }
1846
1847 #[test]
1848 fn parse_diagnostic_builtin_rule_ignores_json_category_and_uses_registry() {
1849 let model = minimal_model();
1850 let val = serde_json::json!({
1851 "rule_id": "E3012",
1852 "severity": Severity::Error.as_str(),
1853 "message": "x",
1854 "category": "general"
1855 });
1856 let diag = parse_diagnostic(&val, &model, None).unwrap();
1857 let expected = lookup_rule("E3012").unwrap().category.as_str();
1858 assert_eq!(
1859 diag.category.as_deref(),
1860 Some(expected),
1861 "a built-in rule takes its category from the registry, ignoring any category in the engine JSON"
1862 );
1863 }
1864
1865 #[test]
1866 fn parse_diagnostic_with_related_locations() {
1867 let model = minimal_model();
1868 let val = serde_json::json!({
1869 "rule_id": "E3012",
1870 "severity": Severity::Error.as_str(),
1871 "message": "x",
1872 "related_locations": [{
1873 "resource_id": "Bucket",
1874 "start_line": 1,
1875 "start_column": 2,
1876 "end_line": 3,
1877 "end_column": 4,
1878 "message": "related"
1879 }]
1880 });
1881 let diag = parse_diagnostic(&val, &model, None).unwrap();
1882 assert_eq!(diag.related_resources.as_ref().unwrap().len(), 1);
1883 assert_eq!(diag.related_resources.as_ref().unwrap()[0].message, "related");
1884 }
1885
1886 #[test]
1887 fn parse_diagnostic_with_condition_scenario() {
1888 let model = minimal_model();
1889 let val = serde_json::json!({
1890 "rule_id": "E3012",
1891 "severity": Severity::Error.as_str(),
1892 "message": "x",
1893 "condition_scenario": {"IsProd": true}
1894 });
1895 let diag = parse_diagnostic(&val, &model, None).unwrap();
1896 let scenario = diag.condition_scenario.unwrap();
1897 assert_eq!(scenario.get("IsProd"), Some(&true));
1898 }
1899
1900 #[test]
1901 fn extract_diagnostics_valid_array() {
1902 let model = minimal_model();
1903 let json = serde_json::json!([
1904 {"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "a"},
1905 {"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "b"}
1906 ]);
1907 let mut out = Vec::new();
1908 extract_diagnostics(&json.to_string(), &model, &mut out, None).unwrap();
1909 assert_eq!(out.len(), 2);
1910 assert_eq!(out[0].rule_id, "E3012");
1911 assert_eq!(out[1].rule_id, "W3045");
1912 }
1913
1914 #[test]
1915 fn extract_diagnostics_invalid_json_is_error() {
1916 let model = minimal_model();
1917 let mut out = Vec::new();
1918 let result = extract_diagnostics("not json", &model, &mut out, None);
1919 result.unwrap_err();
1920 }
1921
1922 #[test]
1923 fn extract_diagnostics_non_array_json_is_error() {
1924 let model = minimal_model();
1925 let mut out = Vec::new();
1926 let result = extract_diagnostics(r#"{"key": "value"}"#, &model, &mut out, None);
1927 result.unwrap_err();
1928 }
1929
1930 #[test]
1931 fn extract_diagnostics_fails_on_missing_required_fields() {
1932 let model = minimal_model();
1933 let json = serde_json::json!([
1934 {"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "ok"},
1935 {"bad": "item"},
1936 {"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "ok2"}
1937 ]);
1938 let mut out = Vec::new();
1939 let result = extract_diagnostics(&json.to_string(), &model, &mut out, None);
1940 result.unwrap_err();
1941 assert_eq!(out.len(), 1, "first valid item should have been added before failure");
1942 }
1943
1944 #[test]
1945 fn extract_diagnostics_from_value_matches_string_extraction() {
1946 let model = minimal_model();
1947 let json = serde_json::json!([
1948 {"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "a", "resource_id": "Bucket",
1949 "resource_path": "Properties.BucketName"},
1950 {"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "b"}
1951 ]);
1952
1953 let mut from_string = Vec::new();
1954 extract_diagnostics(&json.to_string(), &model, &mut from_string, None)
1955 .expect("string extraction should succeed");
1956 let mut from_value = Vec::new();
1957 extract_diagnostics_from_value(&json, &model, &mut from_value, None).expect("value extraction should succeed");
1958
1959 assert_eq!(
1960 serde_json::to_value(&from_string).expect("serialize string-extracted diagnostics"),
1961 serde_json::to_value(&from_value).expect("serialize value-extracted diagnostics"),
1962 "value input and its serialized string form must yield identical diagnostics"
1963 );
1964 }
1965
1966 #[test]
1967 fn extract_diagnostics_from_value_rejects_non_array() {
1968 let model = minimal_model();
1969 let mut out = Vec::new();
1970 extract_diagnostics_from_value(&serde_json::json!({"key": "value"}), &model, &mut out, None)
1971 .expect_err("a non-array value must be rejected the same as the string form");
1972 }
1973
1974 #[test]
1975 fn build_report_counts_severities() {
1976 let model = minimal_model();
1977 let diags = vec![
1978 Diagnostic {
1979 rule_id: "F3012".into(),
1980 severity: Severity::Fatal,
1981 message: "fatal".into(),
1982 ..default_diag()
1983 },
1984 Diagnostic {
1985 rule_id: "E3012".into(),
1986 severity: Severity::Error,
1987 message: "error".into(),
1988 ..default_diag()
1989 },
1990 Diagnostic {
1991 rule_id: "E3013".into(),
1992 severity: Severity::Error,
1993 message: "error2".into(),
1994 ..default_diag()
1995 },
1996 Diagnostic { rule_id: "W3045".into(), severity: Severity::Warn, message: "warn".into(), ..default_diag() },
1997 Diagnostic { severity: Severity::Info, message: "info".into(), ..default_diag() },
1998 ];
1999 let report = build_report_with_versions(
2000 diags,
2001 &model,
2002 3,
2003 Some(50),
2004 false,
2005 Severity::Info,
2006 String::new(),
2007 TEST_CFN_LINT_VERSION.to_string(),
2008 TEST_RESOURCE_SCHEMA_VERSION.to_string(),
2009 );
2010 assert_eq!(report.metadata.counts.fatal, 1);
2011 assert_eq!(report.metadata.counts.errors, 2);
2012 assert_eq!(report.metadata.counts.warnings, 1);
2013 assert_eq!(report.metadata.counts.informational, 1);
2014 assert_eq!(report.metadata.suppressed, 3);
2015 assert_eq!(report.metadata.rules_evaluated, 50);
2016 assert_eq!(report.metadata.cfn_lint_version, TEST_CFN_LINT_VERSION);
2017 assert_eq!(report.metadata.resource_schema_version, TEST_RESOURCE_SCHEMA_VERSION);
2018 assert!(!report.metadata.strict, "default mode should not be strict");
2019 assert_eq!(report.metadata.severity_level, Severity::Info);
2020 assert_eq!(report.metadata.resources_scanned, 1);
2021 assert_eq!(report.diagnostics.len(), 5);
2022 }
2023
2024 #[test]
2025 fn build_report_empty_diagnostics() {
2026 let model = minimal_model();
2027 let report = build_report_with_versions(
2028 vec![],
2029 &model,
2030 0,
2031 None,
2032 true,
2033 Severity::Error,
2034 String::new(),
2035 TEST_CFN_LINT_VERSION.to_string(),
2036 TEST_RESOURCE_SCHEMA_VERSION.to_string(),
2037 );
2038 assert_eq!(report.metadata.rules_evaluated, 0);
2039 assert_eq!(report.metadata.counts.fatal, 0);
2040 assert_eq!(report.metadata.counts.errors, 0);
2041 assert_eq!(report.metadata.counts.warnings, 0);
2042 assert!(report.metadata.strict, "strict mode should be enabled");
2043 assert_eq!(report.metadata.severity_level, Severity::Error);
2044 assert!(report.diagnostics.is_empty());
2045 }
2046
2047 #[test]
2048 fn build_report_debug_severity_counted() {
2049 let model = minimal_model();
2050 let diags = vec![Diagnostic { severity: Severity::Debug, message: "dbg".into(), ..default_diag() }];
2051 let report = build_report_with_versions(
2052 diags,
2053 &model,
2054 0,
2055 None,
2056 false,
2057 Severity::Debug,
2058 String::new(),
2059 TEST_CFN_LINT_VERSION.to_string(),
2060 TEST_RESOURCE_SCHEMA_VERSION.to_string(),
2061 );
2062 assert_eq!(report.metadata.counts.debug, 1);
2063 assert_eq!(report.metadata.counts.informational, 0);
2064 }
2065
2066 #[test]
2067 fn build_rule_list_sorted_by_id() {
2068 let mut meta = HashMap::new();
2069 meta.insert(
2070 "E3012".into(),
2071 RuleMetadataEntry {
2072 category: Some("schema".into()),
2073 description: "Type check".into(),
2074 severity: Severity::Error,
2075 origin: RuleOrigin::Engine,
2076 },
2077 );
2078 meta.insert(
2079 "E0001".into(),
2080 RuleMetadataEntry {
2081 category: Some("structure".into()),
2082 description: "Resources".into(),
2083 severity: Severity::Error,
2084 origin: RuleOrigin::Engine,
2085 },
2086 );
2087 meta.insert(
2088 "W3045".into(),
2089 RuleMetadataEntry {
2090 category: Some("schema".into()),
2091 description: "Enum".into(),
2092 severity: Severity::Warn,
2093 origin: RuleOrigin::Engine,
2094 },
2095 );
2096 let rules = build_rule_list(&meta, &HashMap::new());
2097 assert_eq!(rules.len(), 3);
2098 assert_eq!(rules[0].id, "E0001");
2099 assert_eq!(rules[1].id, "E3012");
2100 assert_eq!(rules[2].id, "W3045");
2101 }
2102
2103 #[test]
2104 fn build_rule_list_uses_stored_severity() {
2105 let mut meta = HashMap::new();
2106 meta.insert(
2107 "F3012".into(),
2108 RuleMetadataEntry {
2109 category: Some("schema".into()),
2110 description: "desc".into(),
2111 severity: Severity::Fatal,
2112 origin: RuleOrigin::Engine,
2113 },
2114 );
2115 meta.insert(
2116 "E3012".into(),
2117 RuleMetadataEntry {
2118 category: Some("schema".into()),
2119 description: "desc".into(),
2120 severity: Severity::Error,
2121 origin: RuleOrigin::Engine,
2122 },
2123 );
2124 meta.insert(
2125 "W3045".into(),
2126 RuleMetadataEntry {
2127 category: Some("schema".into()),
2128 description: "desc".into(),
2129 severity: Severity::Warn,
2130 origin: RuleOrigin::Engine,
2131 },
2132 );
2133 meta.insert(
2134 "I9001".into(),
2135 RuleMetadataEntry {
2136 category: Some("best-practice".into()),
2137 description: "desc".into(),
2138 severity: Severity::Info,
2139 origin: RuleOrigin::Engine,
2140 },
2141 );
2142 let rules = build_rule_list(&meta, &HashMap::new());
2143 let find = |id: &str| rules.iter().find(|r| r.id == id).unwrap();
2144 assert_eq!(find("F3012").severity, Severity::Fatal);
2145 assert_eq!(find("E3012").severity, Severity::Error);
2146 assert_eq!(find("W3045").severity, Severity::Warn);
2147 assert_eq!(find("I9001").severity, Severity::Info);
2148 }
2149
2150 #[test]
2151 fn build_rule_list_schema_category_gets_schema_origin() {
2152 let mut meta = HashMap::new();
2153 meta.insert(
2154 "E3012".into(),
2155 RuleMetadataEntry {
2156 category: Some("schema".into()),
2157 description: "desc".into(),
2158 severity: Severity::Error,
2159 origin: RuleOrigin::Schema,
2160 },
2161 );
2162 meta.insert(
2163 "E0001".into(),
2164 RuleMetadataEntry {
2165 category: Some("structure".into()),
2166 description: "desc".into(),
2167 severity: Severity::Error,
2168 origin: RuleOrigin::Engine,
2169 },
2170 );
2171 let rules = build_rule_list(&meta, &HashMap::new());
2172 let schema_rule = rules.iter().find(|r| r.id == "E3012").unwrap();
2173 assert!(matches!(schema_rule.origin, RuleOrigin::Schema));
2174 let struct_rule = rules.iter().find(|r| r.id == "E0001").unwrap();
2175 assert!(matches!(struct_rule.origin, RuleOrigin::Engine));
2176 }
2177
2178 fn make_diag(rule_id: &str, severity: Severity, line: u32, col: u32) -> Diagnostic {
2179 Diagnostic {
2180 rule_id: rule_id.into(),
2181 severity,
2182 message: format!("msg for {}", rule_id),
2183 location: Some(SourceSpan { start_line: line, start_column: col, end_line: line, end_column: col }),
2184 ..default_diag()
2185 }
2186 }
2187
2188 fn make_transform_error_diag() -> Diagnostic {
2189 Diagnostic {
2190 message: format!(
2191 "{} Resource with id [Fn] is invalid. 'AutoPublishAlias' must be a string or a Ref to a template parameter",
2192 SAM_TRANSFORM_ERROR_PREFIX
2193 ),
2194 ..make_diag(SAM_TRANSFORM_ERROR_RULE_ID, Severity::Error, 1, 1)
2195 }
2196 }
2197
2198 #[test]
2199 fn gate_drops_non_transform_diagnostics_when_transform_error_present() {
2200 let mut diags = vec![
2201 make_diag("E3012", Severity::Error, 5, 1),
2202 make_transform_error_diag(),
2203 make_diag("I9040", Severity::Info, 7, 1),
2204 ];
2205 gate_sam_transform_errors(&mut diags);
2206 assert_eq!(diags.len(), 1);
2207 assert!(is_sam_transform_error_message(&diags[0].message));
2208 }
2209
2210 #[test]
2211 fn gate_keeps_all_diagnostics_when_no_transform_error() {
2212 let mut diags = vec![make_diag("E3012", Severity::Error, 5, 1), make_diag("I9040", Severity::Info, 7, 1)];
2213 gate_sam_transform_errors(&mut diags);
2214 assert_eq!(diags.len(), 2);
2215 }
2216
2217 #[test]
2218 fn finalize_sorts_by_severity_then_rule_number_then_location() {
2219 let config = ValidateConfig::default();
2220 let mut diags = vec![
2221 make_diag("W3045", Severity::Warn, 10, 1),
2222 make_diag("E3012", Severity::Error, 5, 1),
2223 make_diag("F3012", Severity::Fatal, 5, 1),
2224 ];
2225 finalize_diagnostics(&mut diags, &config);
2226 assert_eq!(diags.len(), 3);
2227 assert_eq!(diags[0].rule_id, "F3012");
2229 assert_eq!(diags[1].rule_id, "E3012");
2230 assert_eq!(diags[2].rule_id, "W3045");
2231 }
2232
2233 #[test]
2234 fn finalize_orders_same_severity_by_rule_number() {
2235 let config = ValidateConfig::default();
2236 let mut diags = vec![make_diag("E3999", Severity::Error, 1, 1), make_diag("E3012", Severity::Error, 50, 1)];
2238 finalize_diagnostics(&mut diags, &config);
2239 assert_eq!(diags[0].rule_id, "E3012", "lower rule number sorts first within a severity");
2240 assert_eq!(diags[1].rule_id, "E3999");
2241 }
2242
2243 #[test]
2244 fn finalize_deduplicates_same_span_rule_message() {
2245 let config = ValidateConfig::default();
2246 let d = make_diag("E3012", Severity::Error, 5, 1);
2247 let mut diags = vec![d.clone(), d];
2248 let (total_before, suppressed) = finalize_diagnostics(&mut diags, &config);
2249 assert_eq!(total_before, 2);
2250 assert_eq!(suppressed, 1);
2251 assert_eq!(diags.len(), 1);
2252 }
2253
2254 #[test]
2255 fn finalize_keeps_f_and_e_as_separate_diagnostics() {
2256 let config = ValidateConfig::default();
2257 let mut diags = vec![make_diag("F3012", Severity::Fatal, 5, 1), make_diag("E3012", Severity::Error, 5, 1)];
2258 let (_, suppressed) = finalize_diagnostics(&mut diags, &config);
2259 assert_eq!(suppressed, 0);
2260 assert_eq!(diags.len(), 2);
2261 assert_eq!(diags[0].rule_id, "F3012");
2262 assert_eq!(diags[1].rule_id, "E3012");
2263 }
2264
2265 #[test]
2266 fn finalize_severity_filter_retains_fatal_always() {
2267 let config = ValidateConfig { severity_level: Severity::Error, strict: false, ..Default::default() };
2268 let mut diags = vec![
2269 make_diag("F3012", Severity::Fatal, 1, 1),
2270 make_diag("E3012", Severity::Error, 2, 1),
2271 make_diag("W3045", Severity::Warn, 3, 1),
2272 Diagnostic { severity: Severity::Info, message: "info".into(), location: None, ..default_diag() },
2273 ];
2274 finalize_diagnostics(&mut diags, &config);
2275 assert!(
2276 diags.iter().all(|d| d.severity >= Severity::Error),
2277 "all diagnostics should be Error or above after filtering"
2278 );
2279 assert_eq!(diags.len(), 2);
2280 }
2281
2282 #[test]
2283 fn finalize_different_messages_not_deduped() {
2284 let config = ValidateConfig::default();
2285 let mut d1 = make_diag("E3012", Severity::Error, 5, 1);
2286 let mut d2 = make_diag("E3012", Severity::Error, 5, 1);
2287 d1.message = "message A".into();
2288 d2.message = "message B".into();
2289 let mut diags = vec![d1, d2];
2290 finalize_diagnostics(&mut diags, &config);
2291 assert_eq!(diags.len(), 2);
2292 }
2293
2294 #[test]
2304 fn finalize_dedups_same_rule_message_across_sibling_with_different_message() {
2305 let config = ValidateConfig::default();
2306 let mut a1 = make_diag("W2506", Severity::Warn, 125, 1);
2307 let mut sib = make_diag("W2506", Severity::Warn, 125, 1);
2308 let mut a2 = make_diag("W2506", Severity::Warn, 125, 1);
2309 a1.message = "param pWebServerAMI".into();
2310 sib.message = "param pAppAmi".into();
2311 a2.message = "param pWebServerAMI".into();
2312 let mut diags = vec![a1, sib, a2];
2313 finalize_diagnostics(&mut diags, &config);
2314 assert_eq!(diags.len(), 2, "W2506 pWebServerAMI must dedup across sibling");
2315 let msgs: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
2316 assert!(msgs.contains(&"param pAppAmi"));
2317 assert!(msgs.contains(&"param pWebServerAMI"));
2318 }
2319
2320 #[test]
2321 fn finalize_strict_upgrades_warnings_to_errors() {
2322 let config = ValidateConfig { strict: true, ..Default::default() };
2323 let mut diags = vec![
2324 make_diag("W3045", Severity::Warn, 1, 1),
2325 make_diag("E3012", Severity::Error, 2, 1),
2326 make_diag("F3012", Severity::Fatal, 3, 1),
2327 ];
2328 finalize_diagnostics(&mut diags, &config);
2329 let severity_of = |id: &str| diags.iter().find(|d| d.rule_id == id).map(|d| d.severity);
2330 assert_eq!(severity_of("W3045"), Some(Severity::Error), "Warn should be upgraded to Error");
2331 assert_eq!(severity_of("E3012"), Some(Severity::Error), "Error should stay Error");
2332 assert_eq!(severity_of("F3012"), Some(Severity::Fatal), "Fatal should stay Fatal");
2333 }
2334
2335 #[test]
2336 fn finalize_non_strict_preserves_warning_severity() {
2337 let config = ValidateConfig { strict: false, ..Default::default() };
2338 let mut diags = vec![make_diag("W3045", Severity::Warn, 1, 1), make_diag("E3012", Severity::Error, 2, 1)];
2339 finalize_diagnostics(&mut diags, &config);
2340 let severity_of = |id: &str| diags.iter().find(|d| d.rule_id == id).map(|d| d.severity);
2341 assert_eq!(severity_of("W3045"), Some(Severity::Warn), "Warn should be preserved");
2342 assert_eq!(severity_of("E3012"), Some(Severity::Error), "Error should stay Error");
2343 }
2344
2345 #[test]
2346 fn finalize_disable_builtin_rules_keeps_only_custom_and_guard() {
2347 let config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
2348 let mut diags = vec![
2349 make_diag("F3006", Severity::Fatal, 1, 1),
2350 Diagnostic { source: RuleOrigin::Custom, ..make_diag("CUSTOM001", Severity::Warn, 2, 1) },
2351 Diagnostic { source: RuleOrigin::Guard, ..make_diag("GUARD001", Severity::Warn, 3, 1) },
2352 make_diag("E3012", Severity::Error, 4, 1),
2353 ];
2354 finalize_diagnostics(&mut diags, &config);
2355 assert_eq!(diags.len(), 2);
2356 assert_eq!(diags[0].rule_id, "CUSTOM001");
2357 assert_eq!(diags[1].rule_id, "GUARD001");
2358 }
2359
2360 #[test]
2361 fn finalize_default_config_keeps_all_origins() {
2362 let config = ValidateConfig::default();
2363 let mut diags = vec![
2364 make_diag("F3006", Severity::Fatal, 1, 1),
2365 Diagnostic { source: RuleOrigin::Custom, ..make_diag("CUSTOM001", Severity::Warn, 2, 1) },
2366 ];
2367 finalize_diagnostics(&mut diags, &config);
2368 assert_eq!(diags.len(), 2);
2369 }
2370
2371 #[test]
2372 fn enrich_adds_phase_and_description() {
2373 let model = minimal_model();
2374 let meta = meta_map();
2375 let mut diags = vec![Diagnostic {
2376 rule_id: "E3012".into(),
2377 severity: Severity::Error,
2378 message: "x".into(),
2379 entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
2380 category: Some(Category::Schema.as_str().into()),
2381 ..default_diag()
2382 }];
2383 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2384 assert_eq!(diags[0].phase, Some(Phase::Lint));
2385 assert!(diags[0].rule_description.is_some(), "enriched diagnostic should have rule_description");
2386 }
2387
2388 #[test]
2389 fn enrich_parse_category_gets_parse_phase() {
2390 let model = minimal_model();
2391 let meta = meta_map();
2392 let mut diags = vec![Diagnostic {
2393 rule_id: "F0000".into(),
2394 severity: Severity::Fatal,
2395 message: "dup".into(),
2396 category: Some(Category::Structure.as_str().into()),
2397 phase: Some(Phase::Parse),
2398 ..default_diag()
2399 }];
2400 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2401 assert_eq!(diags[0].phase, Some(Phase::Parse));
2402 }
2403
2404 #[test]
2405 fn enrich_fatal_rule_gets_schema_phase() {
2406 let model = minimal_model();
2407 let meta = meta_map();
2408 let mut diags = vec![Diagnostic {
2409 rule_id: "F3012".into(),
2410 severity: Severity::Fatal,
2411 message: "x".into(),
2412 ..default_diag()
2413 }];
2414 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2415 assert_eq!(diags[0].phase, Some(Phase::Schema));
2416 }
2417
2418 #[test]
2419 fn enrich_custom_rule_gets_no_phase() {
2420 let model = minimal_model();
2421 let meta = meta_map();
2422 let mut diags = vec![Diagnostic {
2423 rule_id: "Firewall".into(),
2424 severity: Severity::Error,
2425 message: "x".into(),
2426 source: RuleOrigin::Custom,
2427 ..default_diag()
2428 }];
2429 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2430 assert_eq!(diags[0].phase, None, "custom rules must not be assigned a phase");
2431 }
2432
2433 #[test]
2434 fn enrich_full_format_builds_context() {
2435 let model = minimal_model();
2436 let meta = meta_map();
2437 let mut diags = vec![Diagnostic {
2438 rule_id: "E3012".into(),
2439 severity: Severity::Error,
2440 message: "x".into(),
2441 entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
2442 property_path: Some("Properties.BucketName".into()),
2443 ..default_diag()
2444 }];
2445 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2446 assert!(diags[0].context.is_some(), "enriched diagnostic should have context");
2447 }
2448
2449 #[test]
2450 fn enrich_standard_format_skips_enrichment() {
2451 let model = minimal_model();
2452 let meta = meta_map();
2453 let mut diags = vec![Diagnostic {
2454 rule_id: "E3012".into(),
2455 severity: Severity::Error,
2456 message: "x".into(),
2457 entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
2458 property_path: Some("Properties.BucketName".into()),
2459 ..default_diag()
2460 }];
2461 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Standard);
2462 assert!(diags[0].phase.is_none(), "unenriched diagnostic should have no phase");
2463 assert!(diags[0].rule_description.is_none(), "unenriched diagnostic should have no rule_description");
2464 assert!(diags[0].context.is_none(), "unenriched diagnostic should have no context");
2465 }
2466
2467 #[test]
2468 fn make_resource_diagnostic_known_rule() {
2469 let model = minimal_model();
2470 let diag = make_resource_diagnostic(
2471 "E3012",
2472 "Type mismatch on BucketName",
2473 &model,
2474 "Bucket",
2475 "Properties.BucketName",
2476 Some("Use a string"),
2477 );
2478 assert_eq!(diag.rule_id, "E3012");
2479 assert_eq!(diag.severity, Severity::Error);
2480 assert_eq!(diag.resource_logical_id(), Some("Bucket"));
2481 assert_eq!(diag.suggested_fix.as_deref(), Some("Use a string"));
2482 }
2483
2484 #[test]
2485 #[should_panic(expected = "Rule 'XBOGUS' not found in RULE_REGISTRY")]
2486 fn make_resource_diagnostic_unknown_rule_panics() {
2487 let model = minimal_model();
2488 make_resource_diagnostic("XBOGUS", "msg", &model, "Bucket", "", None);
2489 }
2490
2491 #[test]
2492 fn make_resource_diagnostic_empty_resource_id() {
2493 let model = minimal_model();
2494 let diag = make_resource_diagnostic("E3012", "msg", &model, "", "", None);
2495 assert!(diag.entity.is_none(), "diagnostic without resource_id should have no entity");
2496 }
2497
2498 #[test]
2499 fn build_context_no_resource_returns_none() {
2500 let model = minimal_model();
2501 assert!(build_context("E3012", None, "Properties.X", &model).is_none(), "no resource should return None");
2502 }
2503
2504 #[test]
2505 fn build_context_unknown_rule_returns_none() {
2506 let model = minimal_model();
2507 assert!(
2508 build_context("XUNKNOWN", Some("Bucket"), "Properties.X", &model).is_none(),
2509 "unknown rule should return None"
2510 );
2511 }
2512
2513 #[test]
2514 fn build_context_e3012_with_property_path() {
2515 let model = minimal_model();
2516 let ctx = build_context("E3012", Some("Bucket"), "Properties.BucketName", &model)
2517 .expect("E3012 with property path should return context");
2518 assert!(ctx.actual_value.is_some(), "context should have actual_value");
2519 }
2520
2521 #[test]
2522 fn build_context_runtime_rules_carry_actual_value_and_lifecycle() {
2523 let yaml = br#"
2524Resources:
2525 Fn:
2526 Type: AWS::Lambda::Function
2527 Properties:
2528 Runtime: nodejs4.3
2529"#;
2530 let model = SemanticModel::from_bytes(yaml).expect("model with lambda runtime");
2531 for (rule_id, expected_lifecycle) in
2532 [("E2533", "end-of-life"), ("E2531", "create-blocked"), ("W2531", "deprecated")]
2533 {
2534 let ctx = build_context(rule_id, Some("Fn"), "Properties.Runtime", &model)
2535 .unwrap_or_else(|| panic!("{rule_id} should return context"));
2536 assert_eq!(
2537 ctx.actual_value.as_ref().and_then(|v| v.as_str()),
2538 Some("nodejs4.3"),
2539 "{rule_id} context should carry the offending runtime string"
2540 );
2541 assert_eq!(ctx.lifecycle.as_deref(), Some(expected_lifecycle), "{rule_id} lifecycle marker");
2542 }
2543 }
2544
2545 #[test]
2546 fn build_context_runtime_omits_actual_value_when_branches_disagree() {
2547 let yaml = br#"
2552Parameters:
2553 Env:
2554 Type: String
2555Conditions:
2556 IsProd: !Equals [!Ref Env, prod]
2557Resources:
2558 Fn:
2559 Type: AWS::Lambda::Function
2560 Properties:
2561 Runtime: !If [IsProd, dotnetcore2.1, nodejs4.3]
2562"#;
2563 let model = SemanticModel::from_bytes(yaml).expect("model with conditional runtime");
2564 let ctx = build_context("E2533", Some("Fn"), "Properties.Runtime", &model)
2565 .expect("E2533 should still return context (lifecycle)");
2566 assert_eq!(ctx.lifecycle.as_deref(), Some("end-of-life"), "lifecycle is unconditionally safe");
2567 assert!(
2568 ctx.actual_value.is_none(),
2569 "actual_value must be omitted when branches resolve to different runtimes, got {:?}",
2570 ctx.actual_value
2571 );
2572 }
2573
2574 #[test]
2575 fn backfill_locations_only_fills_missing_and_resolves_resource_property() {
2576 let yaml = br#"
2577Resources:
2578 Bucket:
2579 Type: AWS::S3::Bucket
2580 Properties:
2581 BucketName: my-bucket
2582"#;
2583 let model = SemanticModel::from_bytes(yaml).expect("model");
2584 let existing = SourceSpan { start_line: 999, start_column: 1, end_line: 999, end_column: 2 };
2585 let mut diags = vec![
2586 Diagnostic {
2588 rule_id: "E3012".into(),
2589 severity: Severity::Error,
2590 message: "x".into(),
2591 entity: Entity::resource("Bucket", None),
2592 property_path: Some("Properties.BucketName".into()),
2593 ..default_diag()
2594 },
2595 Diagnostic {
2597 rule_id: "E3013".into(),
2598 severity: Severity::Error,
2599 message: "y".into(),
2600 location: Some(existing),
2601 ..default_diag()
2602 },
2603 ];
2604 backfill_locations(&mut diags, &model);
2605 assert!(diags[0].location.is_some(), "missing location should be backfilled");
2606 assert_ne!(diags[0].location, Some(UNKNOWN_SPAN), "backfilled location must be a real span");
2607 assert_eq!(diags[1].location, Some(existing), "existing location must be preserved");
2608 }
2609
2610 #[test]
2611 fn backfill_entities_derives_from_entity_paths_without_overriding() {
2612 let model = minimal_model();
2613 let mut diags = vec![
2614 Diagnostic {
2616 rule_id: "E3012".into(),
2617 severity: Severity::Error,
2618 message: "x".into(),
2619 entity: Entity::resource("Bucket", None),
2620 ..default_diag()
2621 },
2622 Diagnostic {
2624 rule_id: "W2001".into(),
2625 severity: Severity::Warn,
2626 message: "y".into(),
2627 property_path: Some("Parameters/MyParam".into()),
2628 ..default_diag()
2629 },
2630 Diagnostic { rule_id: "F0001".into(), severity: Severity::Fatal, message: "z".into(), ..default_diag() },
2632 ];
2633 backfill_entities(&mut diags, &model);
2634 let resource = diags[0].entity.as_ref().expect("resource entity preserved");
2635 assert_eq!(resource.logical_id, "Bucket");
2636 assert_eq!(resource.entity_type, EntityType::Resource);
2637 assert_eq!(
2638 resource.resource_type.as_deref(),
2639 Some("AWS::S3::Bucket"),
2640 "missing resource type is filled from the model"
2641 );
2642 let parameter = diags[1].entity.as_ref().expect("parameter entity derived from path");
2643 assert_eq!(parameter.logical_id, "MyParam");
2644 assert_eq!(parameter.entity_type, EntityType::Parameter);
2645 assert_eq!(parameter.resource_type, None);
2646 assert!(diags[2].entity.is_none(), "template-level finding has no entity");
2647 }
2648
2649 #[test]
2650 fn build_context_e3012_empty_path_returns_none() {
2651 let model = minimal_model();
2652 let ctx = build_context("E3012", Some("Bucket"), "", &model);
2653 assert!(ctx.is_none(), "E3012 with empty path should return None");
2654 }
2655
2656 #[test]
2657 fn build_context_f3002_extracts_property_name() {
2658 let model = minimal_model();
2659 let ctx = build_context("F3002", Some("Bucket"), "Properties.BucketName", &model)
2660 .expect("F3002 should return context");
2661 assert_eq!(ctx.property.as_deref(), Some("BucketName"));
2662 }
2663
2664 #[test]
2665 fn build_context_w3042_sets_deprecated_lifecycle() {
2666 let model = minimal_model();
2667 let ctx = build_context("W9009", Some("Bucket"), "", &model).expect("W9009 should return context");
2668 assert_eq!(ctx.lifecycle.as_deref(), Some("deprecated"));
2669 }
2670
2671 #[test]
2672 fn build_context_i3043_sets_create_only_lifecycle() {
2673 let model = minimal_model();
2674 let ctx = build_context("I9001", Some("Bucket"), "", &model).expect("I9001 should return context");
2675 assert_eq!(ctx.lifecycle.as_deref(), Some("create-only"));
2676 }
2677
2678 #[test]
2679 fn build_context_w9054_sets_write_only_lifecycle() {
2680 let model = minimal_model();
2681 let ctx = build_context("W9054", Some("Bucket"), "", &model).expect("W9054 should return context");
2682 assert_eq!(ctx.lifecycle.as_deref(), Some("write-only"));
2683 }
2684
2685 #[test]
2686 fn parse_diagnostic_debug_severity() {
2687 let model = minimal_model();
2688 let val = serde_json::json!({
2689 "rule_id": "D9999",
2690 "severity": Severity::Debug.as_str(),
2691 "message": "dbg"
2692 });
2693 let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
2694 assert_eq!(diag.severity, Severity::Debug);
2695 }
2696
2697 #[test]
2698 fn parse_diagnostic_no_resource_no_location_resolves_via_section_span() {
2699 let model = minimal_model();
2700 let val = serde_json::json!({
2701 "rule_id": "F0001",
2702 "severity": Severity::Fatal.as_str(),
2703 "message": "no resources"
2704 });
2705 let diag = parse_diagnostic(&val, &model, None).unwrap();
2706 assert_eq!(diag.rule_id, "F0001");
2707 }
2708
2709 #[test]
2710 fn parse_diagnostic_builtin_rule_category_comes_from_registry() {
2711 let model = minimal_model();
2712 let val = serde_json::json!({
2713 "rule_id": "E3012",
2714 "severity": Severity::Error.as_str(),
2715 "message": "x"
2716 });
2717 let diag = parse_diagnostic(&val, &model, None).unwrap();
2718 let expected = lookup_rule("E3012").unwrap().category.as_str();
2719 assert_eq!(diag.category.as_deref(), Some(expected));
2720 }
2721
2722 #[test]
2723 fn build_context_e3030_resolves_actual_value() {
2724 let model = minimal_model();
2725 let ctx = build_context("E3030", Some("Bucket"), "Properties.BucketName", &model)
2726 .expect("E3030 should return context");
2727 assert!(ctx.actual_value.is_some(), "E3030 context should have actual_value");
2728 }
2729
2730 #[test]
2731 fn build_context_f3006_adds_resource_type() {
2732 let model = minimal_model();
2733 let ctx = build_context("F3006", Some("Bucket"), "", &model).expect("F3006 should return context");
2734 let extra = ctx.extra.unwrap();
2735 assert_eq!(extra.get("resource_type").and_then(|v| v.as_str()), Some("AWS::S3::Bucket"));
2736 }
2737
2738 #[test]
2739 fn build_context_f3003_noop_returns_none() {
2740 let model = minimal_model();
2741 assert!(build_context("F3003", Some("Bucket"), "", &model).is_none(), "F3003 should return None");
2742 }
2743
2744 #[test]
2745 fn build_context_e3053_with_property_path() {
2746 let model = minimal_model();
2747 let ctx = build_context("E3053", Some("Bucket"), "Properties.BucketName", &model)
2748 .expect("E3053 should return context");
2749 assert!(ctx.actual_value.is_some(), "E3053 context should have actual_value");
2750 }
2751
2752 #[test]
2753 fn build_context_e3053_empty_path_returns_none() {
2754 let model = minimal_model();
2755 assert!(
2756 build_context("E3053", Some("Bucket"), "", &model).is_none(),
2757 "E3053 with empty path should return None"
2758 );
2759 }
2760
2761 #[test]
2762 fn build_context_e2501_with_condition() {
2763 let yaml = br#"
2764AWSTemplateFormatVersion: "2010-09-09"
2765Conditions:
2766 IsProd:
2767 !Equals [!Ref "AWS::Region", "us-east-1"]
2768Resources:
2769 Bucket:
2770 Type: AWS::S3::Bucket
2771 Condition: IsProd
2772"#;
2773 let model = SemanticModel::from_bytes(yaml).expect("model with condition");
2774 let ctx =
2775 build_context("W2503", Some("Bucket"), "", &model).expect("W2503 with condition should return context");
2776 let extra = ctx.extra.unwrap();
2777 assert!(extra.contains_key("source_condition"));
2778 }
2779
2780 #[test]
2781 fn build_context_e2501_without_condition_returns_none() {
2782 let model = minimal_model();
2783 assert!(
2784 build_context("W2503", Some("Bucket"), "", &model).is_none(),
2785 "W2503 without condition should return None"
2786 );
2787 }
2788
2789 #[test]
2790 fn enrich_preserves_existing_phase_and_description() {
2791 let model = minimal_model();
2792 let meta = meta_map();
2793 let mut diags = vec![Diagnostic {
2794 rule_id: "E3012".into(),
2795 severity: Severity::Error,
2796 message: "x".into(),
2797 phase: Some(Phase::Lint),
2798 rule_description: Some("custom desc".into()),
2799 ..default_diag()
2800 }];
2801 enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Standard);
2802 assert_eq!(diags[0].phase, Some(Phase::Lint));
2803 assert_eq!(diags[0].rule_description.as_deref(), Some("custom desc"));
2804 }
2805
2806 #[test]
2807 fn finalize_warning_level_filters_info() {
2808 let config = ValidateConfig { severity_level: Severity::Warn, ..Default::default() };
2809 let mut diags = vec![
2810 make_diag("F3012", Severity::Fatal, 1, 1),
2811 make_diag("E3012", Severity::Error, 2, 1),
2812 make_diag("W3045", Severity::Warn, 3, 1),
2813 Diagnostic { severity: Severity::Info, message: "info".into(), location: None, ..default_diag() },
2814 ];
2815 finalize_diagnostics(&mut diags, &config);
2816 assert!(
2817 diags.iter().all(|d| d.severity >= Severity::Warn),
2818 "all diagnostics should be Warn or above after filtering"
2819 );
2820 assert_eq!(diags.len(), 3);
2821 }
2822
2823 #[test]
2824 fn finalize_different_property_paths_not_deduped() {
2825 let config = ValidateConfig::default();
2826 let mut d1 = make_diag("E3012", Severity::Error, 5, 1);
2827 let mut d2 = make_diag("E3012", Severity::Error, 5, 1);
2828 d1.property_path = Some("Properties.A".into());
2829 d2.property_path = Some("Properties.B".into());
2830 let mut diags = vec![d1, d2];
2831 finalize_diagnostics(&mut diags, &config);
2832 assert_eq!(diags.len(), 2);
2833 }
2834
2835 #[test]
2836 fn build_rule_list_uses_stored_severity_for_any_rule_id() {
2837 let mut meta = HashMap::new();
2838 meta.insert(
2839 "X9999".into(),
2840 RuleMetadataEntry {
2841 category: Some("custom".into()),
2842 description: "desc".into(),
2843 severity: Severity::Error,
2844 origin: RuleOrigin::Engine,
2845 },
2846 );
2847 let rules = build_rule_list(&meta, &HashMap::new());
2848 assert_eq!(rules[0].severity, Severity::Error);
2849 }
2850
2851 #[test]
2852 fn engine_type_default_is_composite() {
2853 assert_eq!(EngineType::default(), EngineType::Composite);
2854 }
2855
2856 #[test]
2857 fn engine_type_as_str_and_display_name_every_variant() {
2858 for (engine, expected) in
2859 [(EngineType::Rego, "Rego"), (EngineType::Cel, "CEL"), (EngineType::Composite, "Composite")]
2860 {
2861 assert_eq!(engine.as_str(), expected);
2862 assert_eq!(engine.to_string(), expected, "Display must match as_str");
2863 }
2864 }
2865
2866 #[test]
2867 fn engine_type_parse_accepts_case_insensitive_input() {
2868 assert_eq!(EngineType::parse("rego"), Ok(EngineType::Rego));
2869 assert_eq!(EngineType::parse("REGO"), Ok(EngineType::Rego));
2870 assert_eq!(EngineType::parse("Cel"), Ok(EngineType::Cel));
2871 assert_eq!(EngineType::parse("CEL"), Ok(EngineType::Cel));
2872 assert_eq!(EngineType::parse("composite"), Ok(EngineType::Composite));
2873 assert_eq!(EngineType::parse("COMPOSITE"), Ok(EngineType::Composite));
2874 }
2875
2876 #[test]
2877 fn engine_type_parse_returns_error_for_unknown_value() {
2878 let error =
2879 EngineType::parse("unknown").expect_err("an unknown engine selector must return an error, not a default");
2880 assert!(
2881 error.contains("Unknown engine type 'unknown'")
2882 && error.contains("rego")
2883 && error.contains("cel")
2884 && error.contains("composite"),
2885 "the error must name the bad selector and all three valid options, got: {error}"
2886 );
2887 }
2888
2889 #[test]
2890 fn catch_panics_maps_panic_to_caller_error_type() {
2891 #[derive(Debug, PartialEq)]
2894 struct BindingError(String);
2895 let result: Result<(), BindingError> = catch_panics(|| panic!("engine init blew up"), BindingError);
2896 let error = result.expect_err("a panic must be converted to the caller's error type");
2897 assert!(error.0.contains("engine init blew up"), "the panic payload must be carried, got: {}", error.0);
2898 }
2899
2900 #[test]
2901 fn catch_panics_passes_through_ok_and_err_without_wrapping() {
2902 let ok: Result<i32, String> = catch_panics(|| Ok(7), |m| m);
2903 assert_eq!(ok, Ok(7), "a non-panicking Ok must pass through unchanged");
2904 let err: Result<i32, String> = catch_panics(|| Err("plain error".to_string()), |_| "panic".to_string());
2905 assert_eq!(err, Err("plain error".to_string()), "a returned Err must pass through, not be treated as a panic");
2906 }
2907
2908 #[test]
2909 fn validate_catching_panics_converts_panic_to_structured_error() {
2910 let error = validate_catching_panics(|| panic!("simulated invariant violation"))
2911 .expect_err("a panic must be converted into a structured error, not propagated");
2912 match error {
2913 ValidationError::Engine(message) => assert!(
2914 message.contains("Internal validation error") && message.contains("simulated invariant violation"),
2915 "error must wrap the panic payload as a structured engine error, got: {message}"
2916 ),
2917 other => panic!("expected ValidationError::Engine, got {other:?}"),
2918 }
2919 }
2920
2921 #[test]
2922 fn validate_catching_panics_passes_success_through_unchanged() {
2923 let model = minimal_model();
2924 let report = validate_catching_panics(|| {
2925 Ok(build_report_with_versions(
2926 vec![],
2927 &model,
2928 0,
2929 Some(7),
2930 false,
2931 Severity::Info,
2932 "inline".to_string(),
2933 TEST_CFN_LINT_VERSION.to_string(),
2934 TEST_RESOURCE_SCHEMA_VERSION.to_string(),
2935 ))
2936 })
2937 .expect("a non-panicking Ok result must pass through the guard unchanged");
2938 assert_eq!(report.metadata.rules_evaluated, 7, "the guard must return the closure's report verbatim");
2939 }
2940
2941 enum Explosion {
2944 Panic,
2945 ReturnErr,
2946 }
2947
2948 struct ExplodingEngine {
2949 mode: Explosion,
2950 metadata: HashMap<String, RuleMetadataEntry>,
2951 metric: PhaseMetric,
2952 }
2953
2954 impl ExplodingEngine {
2955 fn new(mode: Explosion) -> Self {
2956 Self { mode, metadata: HashMap::new(), metric: PhaseMetric { duration_ms: 0.0 } }
2957 }
2958 }
2959
2960 impl ValidationEngine for ExplodingEngine {
2961 fn engine_name(&self) -> &str {
2962 "exploding-test-engine"
2963 }
2964
2965 fn evaluate_rules(
2966 &self,
2967 _model: &Arc<SemanticModel>,
2968 _config: &ValidateConfig,
2969 ) -> Result<Vec<Diagnostic>, ValidationError> {
2970 match self.mode {
2971 Explosion::Panic => panic!("simulated engine invariant violation"),
2972 Explosion::ReturnErr => Err(ValidationError::Engine("Simulated engine evaluation failure".to_string())),
2973 }
2974 }
2975
2976 fn list_rules(&self) -> Vec<RuleInfo> {
2977 Vec::new()
2978 }
2979
2980 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
2981 &self.metadata
2982 }
2983
2984 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
2985 HashMap::new()
2986 }
2987
2988 fn init_metric(&self) -> &PhaseMetric {
2989 &self.metric
2990 }
2991 }
2992
2993 const WELL_FORMED_TEMPLATE: &[u8] = b"Resources:\n Bucket:\n Type: AWS::S3::Bucket\n";
2994
2995 struct EnrichmentTrackingEngine {
2996 external_metadata_requests: AtomicUsize,
2997 metadata: HashMap<String, RuleMetadataEntry>,
2998 metric: PhaseMetric,
2999 }
3000
3001 impl EnrichmentTrackingEngine {
3002 fn new() -> Self {
3003 Self {
3004 external_metadata_requests: AtomicUsize::new(0),
3005 metadata: build_rule_metadata_map(),
3006 metric: PhaseMetric { duration_ms: 0.0 },
3007 }
3008 }
3009 }
3010
3011 impl ValidationEngine for EnrichmentTrackingEngine {
3012 fn engine_name(&self) -> &str {
3013 "enrichment-tracking-engine"
3014 }
3015
3016 fn evaluate_rules(
3017 &self,
3018 _model: &Arc<SemanticModel>,
3019 _config: &ValidateConfig,
3020 ) -> Result<Vec<Diagnostic>, ValidationError> {
3021 Ok(vec![Diagnostic {
3022 rule_id: "E3012".into(),
3023 severity: Severity::Error,
3024 message: "tracking diagnostic".into(),
3025 ..default_diag()
3026 }])
3027 }
3028
3029 fn list_rules(&self) -> Vec<RuleInfo> {
3030 Vec::new()
3031 }
3032
3033 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
3034 &self.metadata
3035 }
3036
3037 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
3038 self.external_metadata_requests.fetch_add(1, Ordering::SeqCst);
3039 HashMap::new()
3040 }
3041
3042 fn init_metric(&self) -> &PhaseMetric {
3043 &self.metric
3044 }
3045 }
3046
3047 #[test]
3048 fn standard_detail_level_bypasses_enrichment_metadata_lookup() {
3049 let schema_validator = SchemaValidator::default();
3050 let standard_engine = EnrichmentTrackingEngine::new();
3051 let standard_report = validate_bytes(
3052 &standard_engine,
3053 &schema_validator,
3054 WELL_FORMED_TEMPLATE,
3055 ValidateConfig { detail_level: DetailLevel::Standard, ..Default::default() },
3056 )
3057 .expect("STANDARD validation must succeed");
3058
3059 assert_eq!(
3060 standard_engine.external_metadata_requests.load(Ordering::SeqCst),
3061 0,
3062 "STANDARD must not request metadata used only by diagnostic enrichment"
3063 );
3064 let standard_diagnostic = standard_report
3065 .diagnostics
3066 .iter()
3067 .find(|diagnostic| diagnostic.message == "tracking diagnostic")
3068 .expect("the tracking diagnostic must be present");
3069 assert!(standard_diagnostic.phase.is_none());
3070 assert!(standard_diagnostic.rule_description.is_none());
3071 assert!(standard_diagnostic.context.is_none());
3072
3073 let detailed_engine = EnrichmentTrackingEngine::new();
3074 let detailed_report = validate_bytes(
3075 &detailed_engine,
3076 &schema_validator,
3077 WELL_FORMED_TEMPLATE,
3078 ValidateConfig { detail_level: DetailLevel::Detailed, ..Default::default() },
3079 )
3080 .expect("DETAILED validation must succeed");
3081
3082 assert_eq!(
3083 detailed_engine.external_metadata_requests.load(Ordering::SeqCst),
3084 1,
3085 "DETAILED must request external metadata for diagnostic enrichment"
3086 );
3087 let detailed_diagnostic = detailed_report
3088 .diagnostics
3089 .iter()
3090 .find(|diagnostic| diagnostic.message == "tracking diagnostic")
3091 .expect("the tracking diagnostic must be present");
3092 assert_eq!(detailed_diagnostic.phase, Some(Phase::Lint));
3093 assert!(detailed_diagnostic.rule_description.is_some());
3094 }
3095
3096 struct BudgetExhaustingEngine {
3098 kinds: Vec<template_model::BudgetKind>,
3099 metadata: HashMap<String, RuleMetadataEntry>,
3100 metric: PhaseMetric,
3101 }
3102
3103 impl BudgetExhaustingEngine {
3104 fn new(kinds: Vec<template_model::BudgetKind>) -> Self {
3105 Self { kinds, metadata: build_rule_metadata_map(), metric: PhaseMetric { duration_ms: 0.0 } }
3106 }
3107 }
3108
3109 impl ValidationEngine for BudgetExhaustingEngine {
3110 fn engine_name(&self) -> &str {
3111 "budget-test-engine"
3112 }
3113
3114 fn evaluate_rules(
3115 &self,
3116 model: &Arc<SemanticModel>,
3117 _config: &ValidateConfig,
3118 ) -> Result<Vec<Diagnostic>, ValidationError> {
3119 for kind in &self.kinds {
3120 model.record_budget_exhaustion(*kind);
3121 }
3122 Ok(Vec::new())
3123 }
3124
3125 fn list_rules(&self) -> Vec<RuleInfo> {
3126 Vec::new()
3127 }
3128
3129 fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
3130 &self.metadata
3131 }
3132
3133 fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
3134 HashMap::new()
3135 }
3136
3137 fn init_metric(&self) -> &PhaseMetric {
3138 &self.metric
3139 }
3140 }
3141
3142 #[test]
3143 fn budget_exhaustion_emits_one_warning_with_metadata() {
3144 use template_model::BudgetKind;
3145 let engine = BudgetExhaustingEngine::new(vec![
3146 BudgetKind::ResolverDepth,
3147 BudgetKind::EnumExpansion,
3148 BudgetKind::ResolverDepth, ]);
3150 let schema_validator = SchemaValidator::default();
3151 let report = validate_bytes(
3152 &engine,
3153 &schema_validator,
3154 WELL_FORMED_TEMPLATE,
3155 ValidateConfig { detail_level: DetailLevel::Detailed, ..Default::default() },
3156 )
3157 .expect("validation must succeed");
3158
3159 let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
3160 assert_eq!(budget_warnings.len(), 1, "exactly one aggregate budget warning must be emitted");
3161 let w = budget_warnings[0];
3162 assert_eq!(w.severity, Severity::Warn);
3163 assert_eq!(
3164 w.rule_description.as_deref(),
3165 Some("Deterministic validation budgets were exhausted; see report metadata for completeness impact")
3166 );
3167 assert_eq!(w.category.as_deref(), Some(Category::Structure.as_str()));
3168 assert_eq!(w.source, RuleOrigin::Engine);
3169 assert_eq!(w.phase, Some(Phase::Lint));
3170
3171 assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
3172 let budget_exhaustions =
3173 report.metadata.budget_exhaustions.as_deref().expect("exhausted budgets must be present in metadata");
3174 assert_eq!(budget_exhaustions.len(), 2, "two distinct kinds, not three");
3175 assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
3177 assert_eq!(budget_exhaustions[0].description, BudgetKind::ResolverDepth.description());
3178 assert_eq!(budget_exhaustions[0].limit, BudgetKind::ResolverDepth.limit());
3179 assert_eq!(budget_exhaustions[1].kind, "enumExpansion");
3180 assert_eq!(budget_exhaustions[1].description, BudgetKind::EnumExpansion.description());
3181
3182 assert!(w.context.is_some(), "budget warning context must be attached at the DETAILED detail level");
3184 let ctx = w.context.as_ref().unwrap();
3185 let extra = ctx.extra.as_ref().expect("context.extra must be populated");
3186 assert!(extra.contains_key("budgetExhaustions"));
3187 }
3188
3189 #[test]
3190 fn standard_detail_level_skips_budget_warning_enrichment() {
3191 use template_model::BudgetKind;
3192
3193 let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
3194 let schema_validator = SchemaValidator::default();
3195 let report = validate_bytes(
3196 &engine,
3197 &schema_validator,
3198 WELL_FORMED_TEMPLATE,
3199 ValidateConfig { detail_level: DetailLevel::Standard, ..Default::default() },
3200 )
3201 .expect("STANDARD validation must succeed");
3202
3203 let warning = report
3204 .diagnostics
3205 .iter()
3206 .find(|diagnostic| diagnostic.rule_id == "W9052")
3207 .expect("budget exhaustion must still emit its aggregate warning");
3208 assert!(warning.phase.is_none());
3209 assert!(warning.context.is_none());
3210
3211 let budget_exhaustions = report
3212 .metadata
3213 .budget_exhaustions
3214 .as_deref()
3215 .expect("budget metadata must remain available without diagnostic enrichment");
3216 assert_eq!(budget_exhaustions.len(), 1);
3217 assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
3218 }
3219
3220 #[test]
3221 fn context_only_required_property_combinations_keeps_report_status_ok() {
3222 use template_model::BudgetKind;
3223
3224 let choice_count = BudgetKind::RequiredPropertyCombinations.limit() as usize + 1;
3225 let choices: Vec<String> = (0..choice_count).map(|index| format!("Choice{index:03}")).collect();
3226 let properties: serde_json::Map<String, serde_json::Value> =
3227 choices.iter().map(|choice| (choice.clone(), serde_json::json!({"type": "string"}))).collect();
3228 let schema_validator = SchemaValidator::try_with_additional_schemas(vec![(
3229 "AWS::Test::RequiredCombinationBudget",
3230 serde_json::json!({
3231 "properties": properties,
3232 "anyOf": [{"requiredOr": choices}],
3233 "additionalProperties": false
3234 }),
3235 )])
3236 .expect("test schema must compile");
3237 let template = b"Resources:\n R:\n Type: AWS::Test::RequiredCombinationBudget\n Properties: {}\n";
3238 let engine = BudgetExhaustingEngine::new(Vec::new());
3239
3240 let report = validate_bytes(&engine, &schema_validator, template, ValidateConfig::default())
3241 .expect("validation must succeed");
3242
3243 let budget_warnings: Vec<_> =
3244 report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "W9052").collect();
3245 assert_eq!(budget_warnings.len(), 1);
3246 assert_eq!(report.status, ReportStatus::Ok);
3247 let budget_exhaustions =
3248 report.metadata.budget_exhaustions.as_deref().expect("context-only exhaustion must be present in metadata");
3249 assert_eq!(budget_exhaustions.len(), 1);
3250 assert_eq!(budget_exhaustions[0].kind, "requiredPropertyCombinations");
3251 assert_eq!(budget_exhaustions[0].description, BudgetKind::RequiredPropertyCombinations.description());
3252 assert_eq!(budget_exhaustions[0].limit, BudgetKind::RequiredPropertyCombinations.limit());
3253 assert!(!budget_exhaustions[0].analysis_incomplete);
3254 }
3255
3256 #[test]
3257 fn severity_filtering_removes_warning_but_metadata_persists() {
3258 use template_model::BudgetKind;
3259 let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
3260 let schema_validator = SchemaValidator::default();
3261 let report = validate_bytes(
3262 &engine,
3263 &schema_validator,
3264 WELL_FORMED_TEMPLATE,
3265 ValidateConfig { severity_level: Severity::Error, ..Default::default() },
3266 )
3267 .expect("validation must succeed");
3268
3269 let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
3270 assert!(budget_warnings.is_empty(), "severity filter must remove the warning");
3271 assert_eq!(
3272 report.status,
3273 ReportStatus::AnalysisIncomplete,
3274 "status reflects correctness-affecting exhaustion regardless of filter"
3275 );
3276 assert!(report.metadata.budget_exhaustions.is_some());
3277 }
3278
3279 #[test]
3280 fn parse_error_sets_error_status_without_budget_warning() {
3281 let schema_validator = SchemaValidator::default();
3282 let engine = BudgetExhaustingEngine::new(vec![]);
3283 let report = validate_bytes_with_path(
3284 &engine,
3285 &schema_validator,
3286 b"{ not valid yaml or json <<<",
3287 ValidateConfig::default(),
3288 "bad.yaml".to_string(),
3289 )
3290 .expect("parse errors return Ok with error diagnostics");
3291
3292 assert_eq!(report.status, ReportStatus::Error);
3293 assert!(report.metadata.budget_exhaustions.is_none());
3294 let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
3295 assert!(budget_warnings.is_empty(), "parse errors must not emit a budget warning");
3296 }
3297
3298 #[test]
3299 fn category_filtering_removes_warning_but_metadata_persists() {
3300 use template_model::BudgetKind;
3301 let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
3302 let schema_validator = SchemaValidator::default();
3303 let filters = FilterConfig::new(
3304 RuleFilterConfig::default(),
3305 RuleFilterConfig {
3306 categories: vec![Category::Structure.as_str().to_string()],
3307 ..RuleFilterConfig::default()
3308 },
3309 );
3310 let report = validate_bytes(
3311 &engine,
3312 &schema_validator,
3313 WELL_FORMED_TEMPLATE,
3314 ValidateConfig { filters, ..ValidateConfig::default() },
3315 )
3316 .expect("validation must succeed");
3317
3318 assert!(report.diagnostics.iter().all(|diagnostic| diagnostic.rule_id != "W9052"));
3319 assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
3320 let budget_exhaustions = report
3321 .metadata
3322 .budget_exhaustions
3323 .as_deref()
3324 .expect("exhausted budget must be present despite warning suppression");
3325 assert_eq!(budget_exhaustions.len(), 1);
3326 assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
3327 }
3328
3329 #[test]
3330 fn disabling_builtins_suppresses_warning_but_metadata_persists() {
3331 use template_model::BudgetKind;
3332 let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
3333 let schema_validator = SchemaValidator::default();
3334 let report = validate_bytes(
3335 &engine,
3336 &schema_validator,
3337 WELL_FORMED_TEMPLATE,
3338 ValidateConfig { disable_builtin_rules: true, ..ValidateConfig::default() },
3339 )
3340 .expect("validation must succeed");
3341
3342 assert!(report.diagnostics.iter().all(|diagnostic| diagnostic.rule_id != "W9052"));
3343 assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
3344 let budget_exhaustions = report
3345 .metadata
3346 .budget_exhaustions
3347 .as_deref()
3348 .expect("exhausted budget must be present despite warning suppression");
3349 assert_eq!(budget_exhaustions.len(), 1);
3350 assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
3351 }
3352
3353 #[test]
3354 fn non_curtailed_fixture_omits_exhaustions_and_has_ok_status() {
3355 let engine = BudgetExhaustingEngine::new(vec![]); let schema_validator = SchemaValidator::default();
3357 let report = validate_bytes(&engine, &schema_validator, WELL_FORMED_TEMPLATE, ValidateConfig::default())
3358 .expect("validation must succeed");
3359
3360 assert_eq!(report.status, ReportStatus::Ok);
3361 assert!(report.metadata.budget_exhaustions.is_none());
3362 let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
3363 assert!(budget_warnings.is_empty());
3364 }
3365
3366 #[test]
3367 fn engine_exception_surfaces_as_error_never_as_diagnostic() {
3368 let schema_validator = SchemaValidator::default();
3369 let engine = ExplodingEngine::new(Explosion::ReturnErr);
3370 let result = validate_bytes_with_path(
3371 &engine,
3372 &schema_validator,
3373 WELL_FORMED_TEMPLATE,
3374 ValidateConfig::default(),
3375 "inline".to_string(),
3376 );
3377 match result {
3378 Err(ValidationError::Engine(message)) => assert!(
3379 message.contains("Simulated engine evaluation failure"),
3380 "an engine exception must surface verbatim as an error, got: {message}"
3381 ),
3382 Ok(report) => panic!(
3383 "an engine exception must surface as Err, never as an Ok report with \
3384 diagnostics; got {} diagnostics",
3385 report.diagnostics.len()
3386 ),
3387 Err(other) => panic!("expected ValidationError::Engine, got {other:?}"),
3388 }
3389 }
3390
3391 #[test]
3392 fn engine_panic_is_caught_as_error_never_as_diagnostic() {
3393 let schema_validator = SchemaValidator::default();
3394 let engine = ExplodingEngine::new(Explosion::Panic);
3395 let result = validate_catching_panics(|| {
3396 validate_bytes_with_path(
3397 &engine,
3398 &schema_validator,
3399 WELL_FORMED_TEMPLATE,
3400 ValidateConfig::default(),
3401 "inline".to_string(),
3402 )
3403 });
3404 match result {
3405 Err(ValidationError::Engine(message)) => assert!(
3406 message.contains("Internal validation error")
3407 && message.contains("simulated engine invariant violation"),
3408 "a panic must surface as a structured internal error, got: {message}"
3409 ),
3410 Ok(report) => panic!(
3411 "a panic must surface as Err, never as an Ok report with diagnostics; got {} \
3412 diagnostics",
3413 report.diagnostics.len()
3414 ),
3415 Err(other) => panic!("expected ValidationError::Engine, got {other:?}"),
3416 }
3417 }
3418}