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