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