#[cfg(test)]
use data_source::AdditionalSchemaSource;
use data_source::embedded::{CFN_LINT_VERSION, RESOURCE_SCHEMA_VERSION};
use diagnostics::{
BudgetExhaustionRecord, DetailLevel, Diagnostic, Entity, PerformanceMetrics, Phase, PhaseMetric,
RegisteredDiagnostic, RelatedResource, ReportMetadata, ReportStatus, ResourceRef, Summary, ValidationReport,
ViolationContext, apply_filters, diagnostic_from_parse_defect, phase_metric, resolve_section_span,
};
use rules::{
FilterConfig, RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, is_fatal_rule, is_valid_custom_rule_id,
rule_number,
};
use schema_validator::{
OverlayCatalog, SchemaValidationResult, SchemaValidator, SchemaValidatorConfig, build_overlay_catalog,
};
use serde::{Deserialize, Serialize};
use std::any::Any;
use std::collections::HashMap;
use std::error;
use std::fmt;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use template_model::resolved_value::effective_path_from_scenario_source_path;
use template_model::{
EntityType, JsonValue, ParseConfig, ParseError, ParseResult, PseudoParameterOverrides, SemanticModel, SourceSpan,
UNKNOWN_SPAN, entity_identity, is_sam_transform_error_message, region_enums, span_to_option,
};
use web_time::Instant;
pub const DIAGNOSTIC_SOURCE_PATH_FIELD: &str = "source_path";
#[derive(Debug)]
pub enum ValidationError {
Parse(ParseError),
Engine(String),
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationError::Parse(e) => write!(f, "{}", e),
ValidationError::Engine(msg) => write!(f, "{}", msg),
}
}
}
impl error::Error for ValidationError {
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
match self {
ValidationError::Parse(e) => Some(e),
ValidationError::Engine(_) => None,
}
}
}
impl From<ParseError> for ValidationError {
fn from(e: ParseError) -> Self {
ValidationError::Parse(e)
}
}
impl From<String> for ValidationError {
fn from(s: String) -> Self {
ValidationError::Engine(s)
}
}
impl From<&str> for ValidationError {
fn from(s: &str) -> Self {
ValidationError::Engine(s.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum EngineType {
Rego,
Cel,
#[default]
Composite,
}
impl EngineType {
pub const fn as_str(&self) -> &'static str {
match self {
EngineType::Rego => "Rego",
EngineType::Cel => "CEL",
EngineType::Composite => "Composite",
}
}
pub fn parse(raw: &str) -> Result<Self, String> {
match raw.to_lowercase().as_str() {
"rego" => Ok(EngineType::Rego),
"cel" => Ok(EngineType::Cel),
"composite" => Ok(EngineType::Composite),
other => Err(format!("Unknown engine type '{other}'; expected 'rego', 'cel', or 'composite'")),
}
}
}
impl fmt::Display for EngineType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
pub struct ExternalRuleSource {
pub name: String,
pub content: String,
}
#[derive(Default, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
#[serde(rename_all = "camelCase")]
pub struct EngineConfig {
#[serde(default)]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub custom_rules: Vec<ExternalRuleSource>,
#[serde(default)]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub guard_rules: Vec<ExternalRuleSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "wasm-bindings", tsify(optional))]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub schema_validator_config: Option<SchemaValidatorConfig>,
}
impl EngineConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_custom_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
self.custom_rules.extend(rules);
self
}
pub fn with_guard_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
self.guard_rules.extend(rules);
self
}
pub fn with_schema_validator_config(mut self, config: SchemaValidatorConfig) -> Self {
self.schema_validator_config = Some(config);
self
}
#[doc(hidden)]
pub fn build_overlay_catalog(&self) -> Result<OverlayCatalog, ValidationError> {
let additional_schemas = match &self.schema_validator_config {
Some(cfg) if !cfg.additional_schemas.is_empty() => &cfg.additional_schemas,
_ => return Ok(OverlayCatalog::default()),
};
let overlays: Vec<(String, serde_json::Value)> = additional_schemas
.iter()
.map(|s| s.resolve().map_err(|e| ValidationError::Engine(e.0)))
.collect::<Result<Vec<_>, _>>()?;
build_overlay_catalog(overlays)
.map_err(|e| ValidationError::Engine(format!("Failed to apply an additional schema: {e}")))
}
}
#[derive(Default, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
#[serde(rename_all = "camelCase")]
pub struct CompositeEngineConfig {
#[serde(default)]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub rego_rules: Vec<ExternalRuleSource>,
#[serde(default)]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub cel_rules: Vec<ExternalRuleSource>,
#[serde(default)]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub guard_rules: Vec<ExternalRuleSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "wasm-bindings", tsify(optional))]
#[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
pub schema_validator_config: Option<SchemaValidatorConfig>,
}
impl CompositeEngineConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_rego_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
self.rego_rules.extend(rules);
self
}
pub fn with_cel_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
self.cel_rules.extend(rules);
self
}
pub fn with_guard_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
self.guard_rules.extend(rules);
self
}
pub fn with_schema_validator_config(mut self, config: SchemaValidatorConfig) -> Self {
self.schema_validator_config = Some(config);
self
}
}
#[derive(Clone, Default)]
pub struct ValidateConfig {
pub filters: FilterConfig,
pub detail_level: DetailLevel,
pub severity_level: Severity,
pub parameter_overrides: HashMap<String, String>,
pub pseudo_parameter_overrides: PseudoParameterOverrides,
pub strict: bool,
pub disable_builtin_rules: bool,
}
pub trait ValidationEngine {
fn engine_name(&self) -> &str;
fn evaluate_rules(
&self,
model: &Arc<SemanticModel>,
config: &ValidateConfig,
) -> Result<Vec<Diagnostic>, ValidationError>;
fn list_rules(&self) -> Vec<RuleInfo>;
fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry>;
fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry>;
fn init_metric(&self) -> &PhaseMetric;
}
pub(crate) fn validate(
engine: &dyn ValidationEngine,
schema_validator: &SchemaValidator,
result: ParseResult,
config: ValidateConfig,
file_path: String,
) -> Result<ValidationReport, ValidationError> {
let model = Arc::new(result.model);
let model_build = PhaseMetric { duration_ms: result.model_build_ms };
log::info!(
"Validating: {} resources, {} types (engine={})",
model.resources.len(),
model.resources_by_type.len(),
engine.engine_name()
);
let schema_result = if config.disable_builtin_rules {
SchemaValidationResult { diagnostics: Vec::new(), metric: phase_metric(Instant::now()) }
} else {
schema_validator.validate(&model, config.pseudo_parameter_overrides.region.as_deref())
};
let mut all_diagnostics = schema_result.diagnostics;
if !config.disable_builtin_rules {
let invalid = config.pseudo_parameter_overrides.invalid_overrides();
if !invalid.is_empty() {
let message = format!("Invalid pseudo-parameter override(s): {}", invalid.join("; "));
all_diagnostics.push(RegisteredDiagnostic::new("W9012", message).build());
}
}
let t_eval = Instant::now();
let engine_diags = engine.evaluate_rules(&model, &config)?;
all_diagnostics.extend(engine_diags);
let eval_metric = phase_metric(t_eval);
let t_post = Instant::now();
if !config.disable_builtin_rules {
all_diagnostics.extend(crate::step_functions::validate_all_state_machines(&model));
all_diagnostics.extend(model.diagnostics.iter().map(diagnostic_from_parse_defect));
if config.pseudo_parameter_overrides.region.is_none() && region_enums::template_has_region_scoped_value(&model)
{
all_diagnostics.push(
RegisteredDiagnostic::new(
"I9003",
"No region supplied; region-scoped instance/node types were validated against all regions. \
A value reported valid here may still be unavailable in your target region - pass a region to \
validate against it specifically.",
)
.build(),
);
}
}
gate_sam_transform_errors(&mut all_diagnostics);
gate_cdk_suppressed_rules(&mut all_diagnostics, &model);
backfill_locations(&mut all_diagnostics, &model);
backfill_entities(&mut all_diagnostics, &model);
let registry_metadata = engine.rule_metadata();
let external_metadata = config.detail_level.needs_enrichment().then(|| engine.external_rule_metadata());
if let Some(external_metadata) = external_metadata.as_ref() {
enrich_diagnostics(&mut all_diagnostics, &model, registry_metadata, external_metadata, &config.detail_level);
}
if config.detail_level.needs_context() {
schema_validator.enrich_context(&mut all_diagnostics, &model);
for d in all_diagnostics.iter_mut() {
if d.context.is_none() && d.phase != Some(Phase::Schema) {
d.context = build_context(
&d.rule_id,
d.resource_logical_id(),
d.property_path.as_deref().unwrap_or(""),
&model,
);
}
}
}
let exhausted_kinds = model.exhausted_budget_kinds();
let budget_exhaustion_records: Vec<BudgetExhaustionRecord> = exhausted_kinds
.iter()
.map(|kind| BudgetExhaustionRecord {
kind: kind.as_str().to_string(),
description: kind.description().to_string(),
limit: kind.limit(),
analysis_incomplete: kind.analysis_incomplete(),
})
.collect();
let analysis_incomplete = model.budget_analysis_incomplete();
if !config.disable_builtin_rules && !exhausted_kinds.is_empty() {
let message = if analysis_incomplete {
"Deterministic validation budgets were exhausted; analysis may be incomplete and some findings may be omitted"
} else {
"Deterministic validation budgets were exhausted; analysis completed but some detail was omitted"
};
let mut budget_warning = RegisteredDiagnostic::new("W9052", message).build();
if config.detail_level.needs_context() {
let budget_context = budget_exhaustion_records
.iter()
.map(|record| {
serde_json::json!({
"kind": record.kind.as_str(),
"description": record.description.as_str(),
"limit": record.limit,
"analysisIncomplete": record.analysis_incomplete,
})
})
.collect();
let mut context_extra = HashMap::new();
context_extra.insert("budgetExhaustions".to_string(), serde_json::Value::Array(budget_context).into());
budget_warning.context = Some(ViolationContext {
actual_value: None,
expected_constraint: None,
property: None,
lifecycle: None,
resolution_source: None,
extra: Some(context_extra),
});
}
let warning_index = all_diagnostics.len();
all_diagnostics.push(budget_warning);
let warning_slice = &mut all_diagnostics[warning_index..];
backfill_locations(warning_slice, &model);
backfill_entities(warning_slice, &model);
if let Some(external_metadata) = external_metadata.as_ref() {
enrich_diagnostics(warning_slice, &model, registry_metadata, external_metadata, &config.detail_level);
}
}
let (total_before, suppressed) = finalize_diagnostics(&mut all_diagnostics, &config);
if suppressed > 0 {
log::info!("Filtered {} -> {} diagnostics ({} suppressed)", total_before, all_diagnostics.len(), suppressed);
}
let excluded_cats = config.filters.excluded_categories();
let active_rule_count = registry_metadata
.iter()
.filter(|(_, entry)| !entry.category.as_deref().is_some_and(|c| excluded_cats.contains(c)))
.count() as u32;
let mut report = build_report(
all_diagnostics,
&model,
suppressed,
Some(active_rule_count),
config.strict,
config.severity_level,
file_path,
);
if analysis_incomplete {
report.status = ReportStatus::AnalysisIncomplete;
}
report.metadata.budget_exhaustions = (!budget_exhaustion_records.is_empty()).then_some(budget_exhaustion_records);
let finalize_metric = phase_metric(t_post);
report.performance.schema_init = schema_validator.init_metric().clone();
report.performance.engine_init = engine.init_metric().clone();
report.performance.model_build = model_build;
report.performance.schema_validate = schema_result.metric;
report.performance.rule_evaluation = eval_metric;
report.performance.diagnostic_finalize = finalize_metric;
Ok(report)
}
struct DataSourceVersions {
cfn_lint_version: &'static str,
resource_schema_version: &'static str,
}
static DATA_SOURCE_VERSIONS: DataSourceVersions =
DataSourceVersions { cfn_lint_version: CFN_LINT_VERSION, resource_schema_version: RESOURCE_SCHEMA_VERSION };
#[cfg(any(test, feature = "test"))]
pub fn validate_bytes(
engine: &dyn ValidationEngine,
schema_validator: &SchemaValidator,
bytes: &[u8],
config: ValidateConfig,
) -> Result<ValidationReport, ValidationError> {
validate_bytes_with_path(engine, schema_validator, bytes, config, String::new())
}
pub fn validate_bytes_with_path(
engine: &dyn ValidationEngine,
schema_validator: &SchemaValidator,
bytes: &[u8],
config: ValidateConfig,
file_path: String,
) -> Result<ValidationReport, ValidationError> {
let total_start = Instant::now();
let result = match SemanticModel::parse(
bytes,
ParseConfig {
parameters: config.parameter_overrides.clone(),
pseudo_parameters: config.pseudo_parameter_overrides.clone(),
},
) {
Ok(r) => r,
Err(e) => {
let span = match (e.line, e.column) {
(Some(l), Some(c)) => SourceSpan { start_line: l, start_column: c, end_line: l, end_column: c },
_ => UNKNOWN_SPAN,
};
let diag = RegisteredDiagnostic::new("F1101", e.message).location(span).phase(Phase::Parse).build();
let diags = vec![diag];
let report = ValidationReport {
file_path,
status: ReportStatus::Error,
version: env!("CARGO_PKG_VERSION").to_string(),
diagnostics: diags,
metadata: ReportMetadata {
rules_evaluated: 0,
cfn_lint_version: DATA_SOURCE_VERSIONS.cfn_lint_version.to_owned(),
resource_schema_version: DATA_SOURCE_VERSIONS.resource_schema_version.to_owned(),
resources_scanned: 0,
counts: Summary { fatal: 1, errors: 0, warnings: 0, informational: 0, debug: 0 },
suppressed: 0,
strict: config.strict,
severity_level: config.severity_level,
budget_exhaustions: None,
},
performance: PerformanceMetrics {
schema_init: PhaseMetric { duration_ms: 0.0 },
engine_init: PhaseMetric { duration_ms: 0.0 },
model_build: PhaseMetric { duration_ms: 0.0 },
schema_validate: PhaseMetric { duration_ms: 0.0 },
rule_evaluation: PhaseMetric { duration_ms: 0.0 },
diagnostic_finalize: PhaseMetric { duration_ms: 0.0 },
validate_total: phase_metric(total_start),
},
};
return Ok(report);
}
};
let mut report = validate(engine, schema_validator, result, config, file_path)?;
report.performance.validate_total = phase_metric(total_start);
Ok(report)
}
pub fn catch_panics<T, E>(
operation: impl FnOnce() -> Result<T, E>,
on_panic: impl FnOnce(String) -> E,
) -> Result<T, E> {
match catch_unwind(AssertUnwindSafe(operation)) {
Ok(result) => result,
Err(payload) => Err(on_panic(panic_message(payload.as_ref()))),
}
}
pub fn validate_catching_panics<F>(validate: F) -> Result<ValidationReport, ValidationError>
where
F: FnOnce() -> Result<ValidationReport, ValidationError>,
{
catch_panics(validate, |message| ValidationError::Engine(format!("Internal validation error: {message}")))
}
pub fn semantic_model_to_input_json(model: &SemanticModel) -> Result<serde_json::Value, ValidationError> {
serde_json::to_value(model.to_diagnostic_json()).map_err(|e| {
ValidationError::Engine(format!("Failed to serialize the semantic model for rule evaluation: {e}"))
})
}
fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"Panic payload was not a string".to_string()
}
}
fn parse_span_coordinate(
diagnostic: &serde_json::Value,
field_name: &str,
rule_id: &str,
) -> Result<Option<u32>, String> {
let Some(value) = diagnostic.get(field_name) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let coordinate = value
.as_u64()
.ok_or_else(|| format!("Diagnostic '{}' field '{}' must be a non-negative integer", rule_id, field_name))?;
u32::try_from(coordinate)
.map(Some)
.map_err(|_| format!("Diagnostic '{}' field '{}' exceeds the supported coordinate range", rule_id, field_name))
}
fn resolve_model_diagnostic_span(
rule_id: &str,
model: &SemanticModel,
resource_id: Option<&str>,
property_path: Option<&str>,
source_path: Option<&str>,
) -> Result<SourceSpan, String> {
if let Some(source_path) = source_path {
let resource_id = resource_id.ok_or_else(|| {
format!("Diagnostic '{}' provides '{}' without a resource_id", rule_id, DIAGNOSTIC_SOURCE_PATH_FIELD)
})?;
let property_path = property_path.ok_or_else(|| {
format!(
"Diagnostic '{}' on resource '{}' provides '{}' without a resource_path",
rule_id, resource_id, DIAGNOSTIC_SOURCE_PATH_FIELD
)
})?;
let effective_source_path = effective_path_from_scenario_source_path(source_path)
.ok_or_else(|| format!("Diagnostic '{}' has malformed scenario source path '{}'", rule_id, source_path))?;
if effective_source_path != property_path {
return Err(format!(
"Diagnostic '{}' source path '{}' resolves to '{}' instead of resource path '{}'",
rule_id, source_path, effective_source_path, property_path
));
}
}
let location_path = source_path.or(property_path).unwrap_or("");
Ok(if let Some(resource_id) = resource_id {
model.resource_span(resource_id, location_path)
} else {
model.diagnostic_span(None, location_path).unwrap_or_else(|| resolve_section_span(rule_id, model))
})
}
fn validate_explicit_diagnostic_span(
diagnostic: &serde_json::Value,
rule_id: &str,
model_span: SourceSpan,
) -> Result<(), String> {
let start_line = parse_span_coordinate(diagnostic, "start_line", rule_id)?;
let start_column = parse_span_coordinate(diagnostic, "start_column", rule_id)?;
let end_line = parse_span_coordinate(diagnostic, "end_line", rule_id)?;
let end_column = parse_span_coordinate(diagnostic, "end_column", rule_id)?;
if start_line.is_none() && start_column.is_none() && end_line.is_none() && end_column.is_none() {
return Ok(());
}
let (Some(start_line), Some(start_column)) = (start_line, start_column) else {
return Err(format!("Diagnostic '{}' explicit span must provide both start_line and start_column", rule_id));
};
let explicit_end = match (end_line, end_column) {
(None, None) => None,
(Some(line), Some(column)) => Some((line, column)),
_ => {
return Err(format!("Diagnostic '{}' explicit span must provide both end_line and end_column", rule_id));
}
};
if model_span == UNKNOWN_SPAN {
return Err(format!(
"Diagnostic '{}' explicit span cannot be verified because its resource path has no model-derived location",
rule_id
));
}
let start_matches = start_line == model_span.start_line && start_column == model_span.start_column;
let end_matches =
explicit_end.is_none_or(|(line, column)| line == model_span.end_line && column == model_span.end_column);
if !start_matches || !end_matches {
let (explicit_end_line, explicit_end_column) = explicit_end.unwrap_or((start_line, start_column));
return Err(format!(
"Diagnostic '{}' explicit span {}:{}-{}:{} does not match model-derived span {}:{}-{}:{}",
rule_id,
start_line,
start_column,
explicit_end_line,
explicit_end_column,
model_span.start_line,
model_span.start_column,
model_span.end_line,
model_span.end_column
));
}
Ok(())
}
pub(crate) fn parse_diagnostic(
val: &serde_json::Value,
model: &SemanticModel,
source_override: Option<&RuleOrigin>,
) -> Result<Diagnostic, String> {
let rule_id =
val.get("rule_id").and_then(|v| v.as_str()).ok_or("Diagnostic missing required field 'rule_id'")?.to_string();
if rule_id.trim().is_empty() {
return Err("Diagnostic has an empty 'rule_id'".to_string());
}
let message = val
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Diagnostic '{}' missing required field 'message'", rule_id))?
.to_string();
if message.trim().is_empty() {
return Err(format!("Diagnostic '{}' has an empty 'message'", rule_id));
}
let resource_id = val.get("resource_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
let entity = resource_id
.as_ref()
.and_then(|rid| Entity::resource(rid, model.resources.get(rid.as_str()).map(|r| r.resource_type.clone())));
let property_path =
val.get("resource_path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
let source_path = match val.get(DIAGNOSTIC_SOURCE_PATH_FIELD) {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(path)) if !path.is_empty() => Some(path.as_str()),
Some(_) => {
return Err(format!(
"Diagnostic '{}' field '{}' must be a non-empty string",
rule_id, DIAGNOSTIC_SOURCE_PATH_FIELD
));
}
};
let span =
resolve_model_diagnostic_span(&rule_id, model, resource_id.as_deref(), property_path.as_deref(), source_path)?;
validate_explicit_diagnostic_span(val, &rule_id, span)?;
let is_custom_or_guard = source_override.is_some_and(|o| matches!(o, RuleOrigin::Custom | RuleOrigin::Guard));
let suggested_fix = val.get("suggested_fix").and_then(|v| v.as_str()).map(|s| s.to_string());
let documentation_url = val.get("documentation_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let related_resources = val.get("related_locations").and_then(|v| v.as_array()).map(|arr| {
arr.iter()
.filter_map(|r| {
let rel_rid = r.get("resource_id").and_then(|v| v.as_str()).map(|s| s.to_string());
let rel_resource = rel_rid.as_ref().map(|rid| ResourceRef {
id: Some(rid.clone()),
resource_type: model.resources.get(rid.as_str()).map(|res| res.resource_type.clone()),
});
Some(RelatedResource {
resource: rel_resource,
location: Some(SourceSpan {
start_line: r.get("start_line")?.as_u64()? as u32,
start_column: r.get("start_column")?.as_u64()? as u32,
end_line: r.get("end_line")?.as_u64()? as u32,
end_column: r.get("end_column")?.as_u64()? as u32,
}),
message: r.get("message")?.as_str()?.to_string(),
})
})
.collect()
});
let condition_scenario = val
.get("condition_scenario")
.and_then(|v| v.as_object())
.map(|m| m.iter().filter_map(|(k, v)| v.as_bool().map(|b| (k.clone(), b))).collect());
if is_custom_or_guard {
if !is_valid_custom_rule_id(&rule_id) {
return Err(format!(
"Custom/Guard diagnostic '{}' has an invalid 'rule_id': only letters, digits, and the separators \
'_', '.', '-' are allowed",
rule_id
));
}
let severity_str = val
.get("severity")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Custom/Guard diagnostic '{}' missing required field 'severity'", rule_id))?;
let severity = severity_str.parse::<Severity>()?;
let category = val.get("category").and_then(|v| v.as_str()).map(|c| c.to_string());
let source = *source_override.expect("custom/guard branch is only reached with a source override");
let rule_description = (source == RuleOrigin::Custom).then(|| message.clone());
return Ok(Diagnostic {
rule_id,
severity,
message,
entity,
property_path,
suggested_fix,
documentation_url,
category,
location: span_to_option(span),
related_resources,
condition_scenario,
rule_description,
phase: None,
context: None,
source,
});
}
let mut diagnostic = RegisteredDiagnostic::new(rule_id, message)
.location(span)
.suggested_fix(suggested_fix)
.condition_scenario(condition_scenario)
.related_resources(related_resources)
.build();
diagnostic.entity = entity;
diagnostic.property_path = property_path;
diagnostic.documentation_url = documentation_url;
Ok(diagnostic)
}
pub fn extract_diagnostics(
json_str: &str,
model: &SemanticModel,
out: &mut Vec<Diagnostic>,
source_override: Option<&RuleOrigin>,
) -> Result<(), String> {
let json_val: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("Failed to parse diagnostic JSON: {}", e))?;
extract_diagnostics_from_value(&json_val, model, out, source_override)
}
#[doc(hidden)]
pub fn extract_diagnostics_from_value(
json_val: &serde_json::Value,
model: &SemanticModel,
out: &mut Vec<Diagnostic>,
source_override: Option<&RuleOrigin>,
) -> Result<(), String> {
let items = json_val.as_array().ok_or("Diagnostic output must be a JSON array")?;
for item in items {
out.push(parse_diagnostic(item, model, source_override)?);
}
Ok(())
}
pub(crate) fn build_report(
diagnostics: Vec<Diagnostic>,
model: &SemanticModel,
suppressed: u32,
rules_evaluated: Option<u32>,
strict: bool,
severity_level: Severity,
file_path: String,
) -> ValidationReport {
build_report_with_versions(
diagnostics,
model,
suppressed,
rules_evaluated,
strict,
severity_level,
file_path,
DATA_SOURCE_VERSIONS.cfn_lint_version.to_owned(),
DATA_SOURCE_VERSIONS.resource_schema_version.to_owned(),
)
}
fn build_report_with_versions(
diagnostics: Vec<Diagnostic>,
model: &SemanticModel,
suppressed: u32,
rules_evaluated: Option<u32>,
strict: bool,
severity_level: Severity,
file_path: String,
cfn_lint_version: String,
resource_schema_version: String,
) -> ValidationReport {
let fatal = diagnostics.iter().filter(|d| d.severity == Severity::Fatal).count() as u32;
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count() as u32;
let warnings = diagnostics.iter().filter(|d| d.severity == Severity::Warn).count() as u32;
let debug = diagnostics.iter().filter(|d| d.severity == Severity::Debug).count() as u32;
let informational = diagnostics.len() as u32 - fatal - errors - warnings - debug;
ValidationReport {
file_path,
status: ReportStatus::Ok,
version: env!("CARGO_PKG_VERSION").to_string(),
diagnostics,
metadata: ReportMetadata {
rules_evaluated: rules_evaluated.unwrap_or(0),
cfn_lint_version,
resource_schema_version,
resources_scanned: model.resources.len() as u32,
counts: Summary { fatal, errors, warnings, informational, debug },
suppressed,
strict,
severity_level,
budget_exhaustions: None,
},
performance: PerformanceMetrics {
schema_init: PhaseMetric { duration_ms: 0.0 },
engine_init: PhaseMetric { duration_ms: 0.0 },
model_build: PhaseMetric { duration_ms: 0.0 },
schema_validate: PhaseMetric { duration_ms: 0.0 },
rule_evaluation: PhaseMetric { duration_ms: 0.0 },
diagnostic_finalize: PhaseMetric { duration_ms: 0.0 },
validate_total: PhaseMetric { duration_ms: 0.0 },
},
}
}
pub(crate) fn finalize_diagnostics(diagnostics: &mut Vec<Diagnostic>, config: &ValidateConfig) -> (u32, u32) {
let total_before = diagnostics.len() as u32;
if config.disable_builtin_rules {
diagnostics.retain(|d| matches!(d.source, RuleOrigin::Custom | RuleOrigin::Guard));
}
if config.strict {
for d in diagnostics.iter_mut() {
if d.severity == Severity::Warn {
d.severity = Severity::Error;
}
}
}
apply_filters(diagnostics, &config.filters);
diagnostics.retain(|d| d.severity >= config.severity_level);
let line = |d: &Diagnostic| d.location.as_ref().map(|l| l.start_line).unwrap_or(0);
let column = |d: &Diagnostic| d.location.as_ref().map(|l| l.start_column).unwrap_or(0);
fn resource_id(d: &Diagnostic) -> &str {
d.entity.as_ref().map(|e| e.logical_id.as_str()).unwrap_or_default()
}
diagnostics.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| rule_number(&a.rule_id).cmp(&rule_number(&b.rule_id)))
.then_with(|| a.rule_id.cmp(&b.rule_id))
.then_with(|| line(a).cmp(&line(b)))
.then_with(|| column(a).cmp(&column(b)))
.then_with(|| resource_id(a).cmp(resource_id(b)))
.then_with(|| a.property_path.cmp(&b.property_path))
.then_with(|| a.message.cmp(&b.message))
});
diagnostics.dedup_by(|a, b| {
a.location.as_ref().map(|l| l.start_line).unwrap_or(0) == b.location.as_ref().map(|l| l.start_line).unwrap_or(0)
&& a.location.as_ref().map(|l| l.start_column).unwrap_or(0)
== b.location.as_ref().map(|l| l.start_column).unwrap_or(0)
&& a.rule_id == b.rule_id
&& resource_id(a) == resource_id(b)
&& a.message == b.message
&& a.property_path == b.property_path
});
let suppressed = total_before.saturating_sub(diagnostics.len() as u32);
(total_before, suppressed)
}
pub(crate) fn build_context(
rule_id: &str,
resource_id: Option<&str>,
property_path: &str,
model: &SemanticModel,
) -> Option<ViolationContext> {
let rid = resource_id?;
let resolve_val = |path: &str| -> Option<serde_json::Value> {
let scenarios = model.resolve_scenarios_json(rid, path);
scenarios.into_iter().next().map(|(v, _)| v)
};
let unambiguous_val = |path: &str| -> Option<serde_json::Value> {
let mut scenarios = model.resolve_scenarios_json(rid, path).into_iter().map(|(v, _)| v);
let first = scenarios.next()?;
if scenarios.all(|v| v == first) { Some(first) } else { None }
};
let mut actual_value: Option<JsonValue> = None;
let mut property = None;
let mut lifecycle = None;
let mut extra: HashMap<String, JsonValue> = HashMap::new();
match rule_id {
"F3012" | "E3012" => {
if !property_path.is_empty() {
actual_value = resolve_val(property_path).map(Into::into);
}
}
"F3030" | "W3030" | "E3030" | "F3031" | "E3031" | "F3034" | "E3034" | "F3037" | "W3045" | "E1103" | "E1150"
| "E1151" | "E1152" | "E1153" | "E1154" | "E1155" | "E1156" => {
actual_value = resolve_val(property_path).map(Into::into);
}
"F3033" | "W9006" => {
if let Some(v) = resolve_val(property_path) {
if let Some(s) = v.as_str() {
extra.insert("actual_length".into(), serde_json::json!(s.chars().count()).into());
}
actual_value = Some(v.into());
}
}
"F3032" | "E3032" => {
if let Some(v) = resolve_val(property_path)
&& let Some(arr) = v.as_array()
{
extra.insert("actual_count".into(), serde_json::json!(arr.len()).into());
}
}
"F3002" | "E3002" | "F3020" => {
let prop = property_path.rsplit('.').next().unwrap_or("");
if !prop.is_empty() {
property = Some(prop.to_string());
}
}
"F3003" | "F3021" | "F3014" | "F3058" => {}
"E3047" => {
if let Some(v) = resolve_val("Properties.Cpu") {
extra.insert("cpu".into(), v.into());
}
if let Some(v) = resolve_val("Properties.Memory") {
extra.insert("memory".into(), v.into());
}
}
"E3060" => {
if let Some(v) = resolve_val("Properties.CidrBlock") {
extra.insert("cidr".into(), v.into());
}
}
"E9002" => {
actual_value = resolve_val("Properties.SecurityGroupIngress").map(Into::into);
}
"F3006" => {
if let Some(res) = model.resources.get(rid) {
extra.insert("resource_type".into(), serde_json::json!(res.resource_type).into());
}
}
"E3501" => {
if let Some(v) = resolve_val("Properties.QueueName") {
extra.insert("queue_name".into(), v.into());
}
}
"E3505" => {
if let Some(v) = resolve_val("Properties.VisibilityTimeout") {
extra.insert("visibility_timeout".into(), v.into());
}
}
"E3044" => {
if let Some(v) = resolve_val("Properties.LaunchType") {
extra.insert("launch_type".into(), v.into());
}
if let Some(v) = resolve_val("Properties.SchedulingStrategy") {
extra.insert("scheduling_strategy".into(), v.into());
}
}
"E3053" => {
if !property_path.is_empty() {
actual_value = resolve_val(property_path).map(Into::into);
}
}
"W9009" => {
lifecycle = Some("deprecated".into());
}
"I9001" => {
lifecycle = Some("create-only".into());
}
"W9054" => {
lifecycle = Some("write-only".into());
}
"E2533" => {
actual_value = unambiguous_val(property_path).map(Into::into);
lifecycle = Some("end-of-life".into());
}
"E2531" => {
actual_value = unambiguous_val(property_path).map(Into::into);
lifecycle = Some("create-blocked".into());
}
"W2531" => {
actual_value = unambiguous_val(property_path).map(Into::into);
lifecycle = Some("deprecated".into());
}
"W9011" => {
actual_value = unambiguous_val(property_path).map(Into::into);
}
"W2503" | "W2502" => {
if let Some(res) = model.resources.get(rid)
&& let Some(ref c) = res.condition
{
extra.insert("source_condition".into(), serde_json::json!(c).into());
}
}
_ => {}
}
if actual_value.is_none() && property.is_none() && lifecycle.is_none() && extra.is_empty() {
return None;
}
Some(ViolationContext {
actual_value,
expected_constraint: None,
property,
lifecycle,
resolution_source: None,
extra: if extra.is_empty() { None } else { Some(extra) },
})
}
fn gate_sam_transform_errors(diagnostics: &mut Vec<Diagnostic>) {
let has_transform_error = diagnostics.iter().any(|d| is_sam_transform_error_message(&d.message));
if has_transform_error {
diagnostics.retain(|d| is_sam_transform_error_message(&d.message));
}
}
const CDK_SUPPRESSED_RULE_IDS: [&str; 2] = ["I1022", "W3010"];
fn gate_cdk_suppressed_rules(diagnostics: &mut Vec<Diagnostic>, model: &SemanticModel) {
if !model.is_cdk {
return;
}
diagnostics.retain(|d| !CDK_SUPPRESSED_RULE_IDS.contains(&d.rule_id.as_str()));
}
fn backfill_locations(diagnostics: &mut [Diagnostic], model: &SemanticModel) {
for d in diagnostics.iter_mut() {
if d.location.is_some() {
continue;
}
let resource_id = d.resource_logical_id();
let property_path = d.property_path.as_deref().unwrap_or("");
let span = model
.diagnostic_span(resource_id, property_path)
.unwrap_or_else(|| resolve_section_span(&d.rule_id, model));
d.location = span_to_option(span);
}
}
fn backfill_entities(diagnostics: &mut [Diagnostic], model: &SemanticModel) {
for d in diagnostics.iter_mut() {
if d.entity.is_none() {
d.entity = d.property_path.as_deref().and_then(entity_identity).map(|(entity_type, logical_id)| Entity {
logical_id: logical_id.to_string(),
entity_type,
resource_type: None,
});
}
if let Some(entity) = d.entity.as_mut()
&& entity.entity_type == EntityType::Resource
&& entity.resource_type.is_none()
{
entity.resource_type = model.resources.get(entity.logical_id.as_str()).map(|r| r.resource_type.clone());
}
}
}
pub(crate) fn enrich_diagnostics(
diagnostics: &mut [Diagnostic],
model: &SemanticModel,
registry_metadata: &HashMap<String, RuleMetadataEntry>,
external_metadata: &HashMap<String, RuleMetadataEntry>,
format: &DetailLevel,
) {
if !format.needs_enrichment() {
return;
}
let needs_context = format.needs_context();
for d in diagnostics.iter_mut() {
if d.phase.is_none() && !matches!(d.source, RuleOrigin::Custom | RuleOrigin::Guard) {
d.phase = Some(if is_fatal_rule(&d.rule_id) { Phase::Schema } else { Phase::Lint });
}
if d.rule_description.is_none() {
d.rule_description = registry_metadata
.get(&d.rule_id)
.or_else(|| external_metadata.get(&d.rule_id))
.map(|entry| entry.description.clone());
}
if needs_context && d.context.is_none() {
d.context =
build_context(&d.rule_id, d.resource_logical_id(), d.property_path.as_deref().unwrap_or(""), model);
}
}
}
pub fn build_rule_list(
registry: &HashMap<String, RuleMetadataEntry>,
external: &HashMap<String, RuleMetadataEntry>,
) -> Vec<RuleInfo> {
let mut rules: Vec<RuleInfo> = registry
.iter()
.chain(external.iter())
.map(|(id, entry)| RuleInfo {
id: id.clone(),
severity: entry.severity,
category: entry.category.clone(),
description: entry.description.clone(),
origin: entry.origin,
})
.collect();
rules.sort_by(|a, b| a.id.cmp(&b.id));
rules
}
pub fn make_resource_diagnostic(
rule_id: &str,
message: &str,
model: &SemanticModel,
resource_id: &str,
prop_path: &str,
suggested_fix: Option<&str>,
) -> Diagnostic {
make_resource_diagnostic_at_source(rule_id, message, model, resource_id, prop_path, prop_path, suggested_fix)
}
pub fn make_resource_diagnostic_at_source(
rule_id: &str,
message: &str,
model: &SemanticModel,
resource_id: &str,
prop_path: &str,
source_path: &str,
suggested_fix: Option<&str>,
) -> Diagnostic {
let span = if resource_id.is_empty() {
match model.diagnostic_span(None, source_path) {
Some(found) => found,
None => resolve_section_span(rule_id, model),
}
} else {
model.resource_span(resource_id, source_path)
};
RegisteredDiagnostic::new(rule_id, message)
.property_path(prop_path)
.location(span)
.suggested_fix(suggested_fix)
.resource(resource_id, model.resources.get(resource_id).map(|r| r.resource_type.clone()))
.build()
}
#[cfg(test)]
mod tests {
use super::*;
use diagnostics::Phase;
use rules::{Category, RuleFilterConfig, build_rule_metadata_map, lookup_rule};
use std::sync::atomic::{AtomicUsize, Ordering};
use template_model::{SAM_TRANSFORM_ERROR_PREFIX, SAM_TRANSFORM_ERROR_RULE_ID};
const TEST_CFN_LINT_VERSION: &str = "https://github.com/aws-cloudformation/cfn-lint@1.54.0";
const TEST_RESOURCE_SCHEMA_VERSION: &str =
"https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z";
#[test]
fn additional_schema_resolve_uses_explicit_type_name() {
let src = AdditionalSchemaSource {
type_name: Some("AWS::Lambda::Function".into()),
schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
};
let (type_name, schema) = src.resolve().expect("valid schema resolves");
assert_eq!(type_name, "AWS::Lambda::Function", "an explicit type_name names the target type");
assert!(schema.is_object());
}
#[test]
fn additional_schema_resolve_accepts_matching_type_names() {
let src = AdditionalSchemaSource {
type_name: Some("AWS::Lambda::Function".into()),
schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"P":{"type":"string"}}}"#.into(),
};
let (type_name, _) = src.resolve().expect("agreeing type names resolve");
assert_eq!(type_name, "AWS::Lambda::Function");
}
#[test]
fn additional_schema_resolve_rejects_contradictory_type_names() {
let src = AdditionalSchemaSource {
type_name: Some("AWS::Lambda::Function".into()),
schema: r#"{"typeName":"AWS::Other::Type","properties":{}}"#.into(),
};
let err = src.resolve().expect_err("contradictory type names must fail");
assert!(
err.0.contains("AWS::Lambda::Function") && err.0.contains("AWS::Other::Type"),
"the error must name both types, got: {}",
err.0
);
}
#[test]
fn additional_schema_resolve_falls_back_to_schema_type_name() {
let src = AdditionalSchemaSource {
type_name: None,
schema: r#"{"typeName":"AWS::Lambda::Function","properties":{}}"#.into(),
};
let (type_name, _) = src.resolve().expect("valid schema resolves");
assert_eq!(type_name, "AWS::Lambda::Function", "an absent type_name falls back to the schema's typeName");
}
#[test]
fn additional_schema_resolve_rejects_invalid_json() {
let src =
AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{ not json ".into() };
let err = src.resolve().expect_err("invalid JSON must fail");
assert!(err.0.contains("Invalid additional schema"), "unexpected error: {}", err.0);
}
#[test]
fn additional_schema_resolve_rejects_non_object() {
let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "42".into() };
let err = src.resolve().expect_err("non-object schema must fail");
assert!(err.0.contains("expected a JSON object"), "unexpected error: {}", err.0);
}
#[test]
fn additional_schema_resolve_rejects_missing_type_name() {
let src = AdditionalSchemaSource { type_name: None, schema: r#"{"properties":{}}"#.into() };
let err = src.resolve().expect_err("missing type name must fail");
assert!(err.0.contains("missing a resource type name"), "unexpected error: {}", err.0);
}
#[test]
fn additional_schema_resolve_error_names_an_unnamed_source() {
let src = AdditionalSchemaSource { type_name: None, schema: "{ not json ".into() };
let err = src.resolve().expect_err("invalid JSON must fail");
assert!(err.0.contains("<unnamed>"), "unexpected error: {}", err.0);
}
#[test]
fn engine_config_build_overlay_catalog_applies_schemas() {
let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
additional_schemas: vec![AdditionalSchemaSource {
type_name: None,
schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
.into(),
}],
});
let catalog = config.build_overlay_catalog().expect("valid overlay builds catalog");
assert!(!catalog.is_empty());
assert!(catalog.type_names.contains(&"AWS::Lambda::Function".to_string()));
}
#[test]
fn engine_config_build_overlay_catalog_empty_is_noop() {
let config = EngineConfig::default();
let catalog = config.build_overlay_catalog().expect("empty config builds empty catalog");
assert!(catalog.is_empty());
}
#[test]
fn engine_config_builder_composes_without_a_struct_literal() {
let config = EngineConfig::new()
.with_custom_rules([ExternalRuleSource { name: "a.rego".into(), content: "package a".into() }])
.with_guard_rules([ExternalRuleSource { name: "b.guard".into(), content: "rule x {}".into() }])
.with_schema_validator_config(SchemaValidatorConfig {
additional_schemas: vec![AdditionalSchemaSource {
type_name: Some("AWS::Test::One".into()),
schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
}],
});
assert_eq!(config.custom_rules.len(), 1);
assert_eq!(config.guard_rules.len(), 1);
let sv_config = config.schema_validator_config.as_ref().expect("schema_validator config must be set");
assert_eq!(sv_config.additional_schemas.len(), 1);
assert_eq!(sv_config.additional_schemas[0].type_name.as_deref(), Some("AWS::Test::One"));
}
#[test]
fn default_engine_config_serializes_without_the_schema_validator_config_field() {
let json = serde_json::to_value(EngineConfig::default()).expect("EngineConfig serializes");
assert!(
json.get("schemaValidatorConfig").is_none(),
"an empty schema_validator_config must not change the serialized form, got: {json}"
);
}
#[test]
fn engine_config_nested_schema_validator_serialization_roundtrip() {
let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
additional_schemas: vec![AdditionalSchemaSource {
type_name: Some("AWS::Test::RT".into()),
schema: r#"{"properties":{}}"#.into(),
}],
});
let json = serde_json::to_value(&config).expect("serializes");
assert!(json.get("schemaValidatorConfig").is_some(), "nested config must appear in JSON: {json}");
assert!(json.get("schemaValidator").is_none(), "the old config field must not be serialized: {json}");
let deserialized: EngineConfig = serde_json::from_value(json).expect("deserializes");
let sv = deserialized.schema_validator_config.expect("nested config must roundtrip");
assert_eq!(sv.additional_schemas.len(), 1);
assert_eq!(sv.additional_schemas[0].type_name.as_deref(), Some("AWS::Test::RT"));
}
#[test]
fn standalone_engine_with_nested_schema_config_derives_metadata() {
let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
additional_schemas: vec![AdditionalSchemaSource {
type_name: None,
schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
.into(),
}],
});
let catalog = config.build_overlay_catalog().expect("nested config builds");
assert!(!catalog.is_empty(), "the catalog must be populated from nested config");
}
#[test]
fn combined_wrapper_style_path_constructs_validator_and_engine_once() {
let schema_config = SchemaValidatorConfig {
additional_schemas: vec![AdditionalSchemaSource {
type_name: None,
schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
.into(),
}],
};
let validator = schema_validator::SchemaValidator::new(schema_config).expect("validator builds");
assert!(!validator.overlay_catalog().is_empty());
assert!(validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()));
}
#[test]
fn standalone_schema_validator_config() {
let config = SchemaValidatorConfig {
additional_schemas: vec![AdditionalSchemaSource {
type_name: None,
schema: r#"{"typeName":"AWS::Test::Standalone","properties":{"Name":{"type":"string"}}}"#.into(),
}],
};
let validator = schema_validator::SchemaValidator::new(config).expect("standalone validator builds");
assert!(validator.overlay_catalog().type_names.contains(&"AWS::Test::Standalone".to_string()));
}
fn minimal_model() -> SemanticModel {
let yaml = br#"
AWSTemplateFormatVersion: "2010-09-09"
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: my-bucket
"#;
SemanticModel::from_bytes(yaml).expect("minimal model should parse")
}
fn meta_map() -> HashMap<String, RuleMetadataEntry> {
build_rule_metadata_map()
}
fn default_diag() -> Diagnostic {
Diagnostic {
rule_id: String::new(),
severity: Severity::Info,
message: String::new(),
entity: None,
property_path: None,
suggested_fix: None,
documentation_url: None,
category: None,
location: None,
related_resources: None,
condition_scenario: None,
rule_description: None,
phase: None,
context: None,
source: RuleOrigin::Engine,
}
}
#[test]
fn parse_diagnostic_minimal_valid() {
let model = minimal_model();
let val: serde_json::Value = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "Type mismatch",
"resource_id": "Bucket",
"resource_path": "Properties.BucketName"
});
let diag = parse_diagnostic(&val, &model, None).expect("should parse");
assert_eq!(diag.rule_id, "E3012");
assert_eq!(diag.severity, Severity::Error);
assert_eq!(diag.message, "Type mismatch");
assert_eq!(diag.resource_logical_id(), Some("Bucket"));
assert_eq!(diag.entity.as_ref().and_then(|e| e.resource_type.as_deref()), Some("AWS::S3::Bucket"));
}
#[test]
fn parse_diagnostic_missing_rule_id_returns_error() {
let model = minimal_model();
let val = serde_json::json!({"severity": Severity::Error.as_str(), "message": "x"});
parse_diagnostic(&val, &model, None).unwrap_err();
}
#[test]
fn parse_diagnostic_missing_severity_for_builtin_is_accepted() {
let model = minimal_model();
let val = serde_json::json!({"rule_id": "E3012", "message": "x"});
let diag = parse_diagnostic(&val, &model, None).expect("built-in diagnostic must not require severity in JSON");
assert_eq!(diag.severity, Severity::Error, "registry-derived severity must win for built-in rules");
}
#[test]
fn parse_diagnostic_missing_severity_for_custom_returns_error() {
let model = minimal_model();
let val = serde_json::json!({"rule_id": "XCUSTOM", "message": "x"});
parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
.expect_err("custom/guard diagnostics without severity must error");
}
#[test]
fn parse_diagnostic_builtin_ignores_json_severity() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Warn.as_str(),
"message": "x"
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
assert_eq!(
diag.severity,
Severity::Error,
"the registry's Error severity for E3012 must override any JSON severity"
);
}
#[test]
fn parse_diagnostic_missing_message_returns_error() {
let model = minimal_model();
let val = serde_json::json!({"rule_id": "E3012", "severity": Severity::Error.as_str()});
parse_diagnostic(&val, &model, None).unwrap_err();
}
#[test]
fn parse_diagnostic_empty_rule_id_returns_error() {
let model = minimal_model();
let val = serde_json::json!({"rule_id": "", "severity": Severity::Error.as_str(), "message": "x"});
let err = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
.expect_err("an empty rule_id must be rejected, not accepted as a blank-ID diagnostic");
assert!(err.contains("empty 'rule_id'"), "got: {err}");
}
#[test]
fn parse_diagnostic_empty_message_returns_error() {
let model = minimal_model();
let val = serde_json::json!({"rule_id": "CUSTOM1", "severity": Severity::Error.as_str(), "message": " "});
let err =
parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).expect_err("a blank message must be rejected");
assert!(err.contains("CUSTOM1") && err.contains("empty 'message'"), "got: {err}");
}
#[test]
fn parse_diagnostic_custom_severity_is_authoritative_even_for_f_prefixed_id() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "FCUSTOM",
"severity": Severity::Error.as_str(),
"message": "type mismatch"
});
let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
assert_eq!(diag.severity, Severity::Error, "declared severity must survive an F-prefixed custom ID");
}
#[test]
fn parse_diagnostic_custom_rule_id_allows_separators_but_rejects_punctuation() {
let model = minimal_model();
let ok = serde_json::json!({
"rule_id": "s3.encryption-required_1",
"severity": Severity::Warn.as_str(),
"message": "ok"
});
assert!(parse_diagnostic(&ok, &model, Some(&RuleOrigin::Custom)).is_ok());
let bad = serde_json::json!({
"rule_id": "bad id/with space",
"severity": Severity::Warn.as_str(),
"message": "bad"
});
let err = parse_diagnostic(&bad, &model, Some(&RuleOrigin::Custom))
.expect_err("a rule_id with whitespace/punctuation must be rejected");
assert!(err.contains("invalid 'rule_id'"), "got: {err}");
}
#[test]
fn parse_diagnostic_warning_severity() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "W3045",
"severity": Severity::Warn.as_str(),
"message": "warn"
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
assert_eq!(diag.severity, Severity::Warn);
}
#[test]
fn parse_diagnostic_with_suggested_fix_and_doc_url() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "bad",
"suggested_fix": "fix it",
"documentation_url": "https://example.com"
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
assert_eq!(diag.suggested_fix.as_deref(), Some("fix it"));
assert_eq!(diag.documentation_url.as_deref(), Some("https://example.com"));
}
#[test]
fn parse_diagnostic_empty_resource_id_treated_as_none() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"resource_id": ""
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
assert!(diag.entity.is_none(), "diagnostic without resource_id should have no entity");
}
#[test]
fn parse_diagnostic_accepts_model_matching_explicit_location() {
let model = minimal_model();
let expected = model.resource_span("Bucket", "Properties.BucketName");
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"resource_id": "Bucket",
"resource_path": "Properties.BucketName",
"start_line": expected.start_line,
"start_column": expected.start_column,
"end_line": expected.end_line,
"end_column": expected.end_column
});
let diag = parse_diagnostic(&val, &model, None).expect("model-matching coordinates should be accepted");
assert_eq!(diag.location, Some(expected));
}
#[test]
fn parse_diagnostic_rejects_unverified_explicit_location() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "XCUSTOM",
"severity": Severity::Error.as_str(),
"message": "x",
"resource_id": "Bucket",
"resource_path": "Properties.BucketName",
"start_line": 42,
"start_column": 7,
"end_line": 43,
"end_column": 9
});
let error = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
.expect_err("custom coordinates outside the declared property must be rejected");
assert!(error.contains("does not match model-derived span"), "got: {error}");
}
#[test]
fn parse_diagnostic_accepts_verified_conditional_source_path() {
let model = SemanticModel::from_bytes(
br#"
Parameters:
Mode: {Type: String}
Conditions:
UseFirst: !Equals [!Ref Mode, first]
Resources:
Record:
Type: Custom::Record
Properties:
Values: !If
- UseFirst
- [invalid]
- [valid]
"#,
)
.expect("conditional model should parse");
let source_path = "Properties.Values.Fn::If.1.0";
let expected = model.resource_span("Record", source_path);
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"resource_id": "Record",
"resource_path": "Properties.Values.0",
(DIAGNOSTIC_SOURCE_PATH_FIELD): source_path,
"start_line": expected.start_line,
"start_column": expected.start_column,
"end_line": expected.end_line,
"end_column": expected.end_column
});
let diag = parse_diagnostic(&val, &model, None).expect("verified conditional source path should be accepted");
assert_eq!(diag.location, Some(expected));
}
#[test]
fn parse_diagnostic_rejects_source_path_for_another_property() {
let model = minimal_model();
let expected = model.resource_span("Bucket", "Properties.BucketName");
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"resource_id": "Bucket",
"resource_path": "Properties.BucketName",
(DIAGNOSTIC_SOURCE_PATH_FIELD): "Properties.Tags.Fn::If.1.0",
"start_line": expected.start_line,
"start_column": expected.start_column,
"end_line": expected.end_line,
"end_column": expected.end_column
});
let error = parse_diagnostic(&val, &model, None)
.expect_err("a source path for another property must not relocate the diagnostic");
assert!(error.contains("instead of resource path 'Properties.BucketName'"), "got: {error}");
}
#[test]
fn parse_diagnostic_unknown_rule_uses_json_category() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "XUNKNOWN",
"severity": Severity::Error.as_str(),
"message": "x",
"category": "my-category"
});
let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
assert_eq!(diag.category.as_deref(), Some("my-category"));
}
#[test]
fn parse_diagnostic_builtin_rule_ignores_json_category_and_uses_registry() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"category": "general"
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
let expected = lookup_rule("E3012").unwrap().category.as_str();
assert_eq!(
diag.category.as_deref(),
Some(expected),
"a built-in rule takes its category from the registry, ignoring any category in the engine JSON"
);
}
#[test]
fn parse_diagnostic_with_related_locations() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"related_locations": [{
"resource_id": "Bucket",
"start_line": 1,
"start_column": 2,
"end_line": 3,
"end_column": 4,
"message": "related"
}]
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
assert_eq!(diag.related_resources.as_ref().unwrap().len(), 1);
assert_eq!(diag.related_resources.as_ref().unwrap()[0].message, "related");
}
#[test]
fn parse_diagnostic_with_condition_scenario() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x",
"condition_scenario": {"IsProd": true}
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
let scenario = diag.condition_scenario.unwrap();
assert_eq!(scenario.get("IsProd"), Some(&true));
}
#[test]
fn extract_diagnostics_valid_array() {
let model = minimal_model();
let json = serde_json::json!([
{"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "a"},
{"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "b"}
]);
let mut out = Vec::new();
extract_diagnostics(&json.to_string(), &model, &mut out, None).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].rule_id, "E3012");
assert_eq!(out[1].rule_id, "W3045");
}
#[test]
fn extract_diagnostics_invalid_json_is_error() {
let model = minimal_model();
let mut out = Vec::new();
let result = extract_diagnostics("not json", &model, &mut out, None);
result.unwrap_err();
}
#[test]
fn extract_diagnostics_non_array_json_is_error() {
let model = minimal_model();
let mut out = Vec::new();
let result = extract_diagnostics(r#"{"key": "value"}"#, &model, &mut out, None);
result.unwrap_err();
}
#[test]
fn extract_diagnostics_fails_on_missing_required_fields() {
let model = minimal_model();
let json = serde_json::json!([
{"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "ok"},
{"bad": "item"},
{"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "ok2"}
]);
let mut out = Vec::new();
let result = extract_diagnostics(&json.to_string(), &model, &mut out, None);
result.unwrap_err();
assert_eq!(out.len(), 1, "first valid item should have been added before failure");
}
#[test]
fn extract_diagnostics_from_value_matches_string_extraction() {
let model = minimal_model();
let json = serde_json::json!([
{"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "a", "resource_id": "Bucket",
"resource_path": "Properties.BucketName"},
{"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "b"}
]);
let mut from_string = Vec::new();
extract_diagnostics(&json.to_string(), &model, &mut from_string, None)
.expect("string extraction should succeed");
let mut from_value = Vec::new();
extract_diagnostics_from_value(&json, &model, &mut from_value, None).expect("value extraction should succeed");
assert_eq!(
serde_json::to_value(&from_string).expect("serialize string-extracted diagnostics"),
serde_json::to_value(&from_value).expect("serialize value-extracted diagnostics"),
"value input and its serialized string form must yield identical diagnostics"
);
}
#[test]
fn extract_diagnostics_from_value_rejects_non_array() {
let model = minimal_model();
let mut out = Vec::new();
extract_diagnostics_from_value(&serde_json::json!({"key": "value"}), &model, &mut out, None)
.expect_err("a non-array value must be rejected the same as the string form");
}
#[test]
fn build_report_counts_severities() {
let model = minimal_model();
let diags = vec![
Diagnostic {
rule_id: "F3012".into(),
severity: Severity::Fatal,
message: "fatal".into(),
..default_diag()
},
Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "error".into(),
..default_diag()
},
Diagnostic {
rule_id: "E3013".into(),
severity: Severity::Error,
message: "error2".into(),
..default_diag()
},
Diagnostic { rule_id: "W3045".into(), severity: Severity::Warn, message: "warn".into(), ..default_diag() },
Diagnostic { severity: Severity::Info, message: "info".into(), ..default_diag() },
];
let report = build_report_with_versions(
diags,
&model,
3,
Some(50),
false,
Severity::Info,
String::new(),
TEST_CFN_LINT_VERSION.to_string(),
TEST_RESOURCE_SCHEMA_VERSION.to_string(),
);
assert_eq!(report.metadata.counts.fatal, 1);
assert_eq!(report.metadata.counts.errors, 2);
assert_eq!(report.metadata.counts.warnings, 1);
assert_eq!(report.metadata.counts.informational, 1);
assert_eq!(report.metadata.suppressed, 3);
assert_eq!(report.metadata.rules_evaluated, 50);
assert_eq!(report.metadata.cfn_lint_version, TEST_CFN_LINT_VERSION);
assert_eq!(report.metadata.resource_schema_version, TEST_RESOURCE_SCHEMA_VERSION);
assert!(!report.metadata.strict, "default mode should not be strict");
assert_eq!(report.metadata.severity_level, Severity::Info);
assert_eq!(report.metadata.resources_scanned, 1);
assert_eq!(report.diagnostics.len(), 5);
}
#[test]
fn build_report_empty_diagnostics() {
let model = minimal_model();
let report = build_report_with_versions(
vec![],
&model,
0,
None,
true,
Severity::Error,
String::new(),
TEST_CFN_LINT_VERSION.to_string(),
TEST_RESOURCE_SCHEMA_VERSION.to_string(),
);
assert_eq!(report.metadata.rules_evaluated, 0);
assert_eq!(report.metadata.counts.fatal, 0);
assert_eq!(report.metadata.counts.errors, 0);
assert_eq!(report.metadata.counts.warnings, 0);
assert!(report.metadata.strict, "strict mode should be enabled");
assert_eq!(report.metadata.severity_level, Severity::Error);
assert!(report.diagnostics.is_empty());
}
#[test]
fn build_report_debug_severity_counted() {
let model = minimal_model();
let diags = vec![Diagnostic { severity: Severity::Debug, message: "dbg".into(), ..default_diag() }];
let report = build_report_with_versions(
diags,
&model,
0,
None,
false,
Severity::Debug,
String::new(),
TEST_CFN_LINT_VERSION.to_string(),
TEST_RESOURCE_SCHEMA_VERSION.to_string(),
);
assert_eq!(report.metadata.counts.debug, 1);
assert_eq!(report.metadata.counts.informational, 0);
}
#[test]
fn build_rule_list_sorted_by_id() {
let mut meta = HashMap::new();
meta.insert(
"E3012".into(),
RuleMetadataEntry {
category: Some("schema".into()),
description: "Type check".into(),
severity: Severity::Error,
origin: RuleOrigin::Engine,
},
);
meta.insert(
"E0001".into(),
RuleMetadataEntry {
category: Some("structure".into()),
description: "Resources".into(),
severity: Severity::Error,
origin: RuleOrigin::Engine,
},
);
meta.insert(
"W3045".into(),
RuleMetadataEntry {
category: Some("schema".into()),
description: "Enum".into(),
severity: Severity::Warn,
origin: RuleOrigin::Engine,
},
);
let rules = build_rule_list(&meta, &HashMap::new());
assert_eq!(rules.len(), 3);
assert_eq!(rules[0].id, "E0001");
assert_eq!(rules[1].id, "E3012");
assert_eq!(rules[2].id, "W3045");
}
#[test]
fn build_rule_list_uses_stored_severity() {
let mut meta = HashMap::new();
meta.insert(
"F3012".into(),
RuleMetadataEntry {
category: Some("schema".into()),
description: "desc".into(),
severity: Severity::Fatal,
origin: RuleOrigin::Engine,
},
);
meta.insert(
"E3012".into(),
RuleMetadataEntry {
category: Some("schema".into()),
description: "desc".into(),
severity: Severity::Error,
origin: RuleOrigin::Engine,
},
);
meta.insert(
"W3045".into(),
RuleMetadataEntry {
category: Some("schema".into()),
description: "desc".into(),
severity: Severity::Warn,
origin: RuleOrigin::Engine,
},
);
meta.insert(
"I9001".into(),
RuleMetadataEntry {
category: Some("best-practice".into()),
description: "desc".into(),
severity: Severity::Info,
origin: RuleOrigin::Engine,
},
);
let rules = build_rule_list(&meta, &HashMap::new());
let find = |id: &str| rules.iter().find(|r| r.id == id).unwrap();
assert_eq!(find("F3012").severity, Severity::Fatal);
assert_eq!(find("E3012").severity, Severity::Error);
assert_eq!(find("W3045").severity, Severity::Warn);
assert_eq!(find("I9001").severity, Severity::Info);
}
#[test]
fn build_rule_list_schema_category_gets_schema_origin() {
let mut meta = HashMap::new();
meta.insert(
"E3012".into(),
RuleMetadataEntry {
category: Some("schema".into()),
description: "desc".into(),
severity: Severity::Error,
origin: RuleOrigin::Schema,
},
);
meta.insert(
"E0001".into(),
RuleMetadataEntry {
category: Some("structure".into()),
description: "desc".into(),
severity: Severity::Error,
origin: RuleOrigin::Engine,
},
);
let rules = build_rule_list(&meta, &HashMap::new());
let schema_rule = rules.iter().find(|r| r.id == "E3012").unwrap();
assert!(matches!(schema_rule.origin, RuleOrigin::Schema));
let struct_rule = rules.iter().find(|r| r.id == "E0001").unwrap();
assert!(matches!(struct_rule.origin, RuleOrigin::Engine));
}
fn make_diag(rule_id: &str, severity: Severity, line: u32, col: u32) -> Diagnostic {
Diagnostic {
rule_id: rule_id.into(),
severity,
message: format!("msg for {}", rule_id),
location: Some(SourceSpan { start_line: line, start_column: col, end_line: line, end_column: col }),
..default_diag()
}
}
fn make_transform_error_diag() -> Diagnostic {
Diagnostic {
message: format!(
"{} Resource with id [Fn] is invalid. 'AutoPublishAlias' must be a string or a Ref to a template parameter",
SAM_TRANSFORM_ERROR_PREFIX
),
..make_diag(SAM_TRANSFORM_ERROR_RULE_ID, Severity::Error, 1, 1)
}
}
#[test]
fn gate_drops_non_transform_diagnostics_when_transform_error_present() {
let mut diags = vec![
make_diag("E3012", Severity::Error, 5, 1),
make_transform_error_diag(),
make_diag("I9040", Severity::Info, 7, 1),
];
gate_sam_transform_errors(&mut diags);
assert_eq!(diags.len(), 1);
assert!(is_sam_transform_error_message(&diags[0].message));
}
#[test]
fn gate_keeps_all_diagnostics_when_no_transform_error() {
let mut diags = vec![make_diag("E3012", Severity::Error, 5, 1), make_diag("I9040", Severity::Info, 7, 1)];
gate_sam_transform_errors(&mut diags);
assert_eq!(diags.len(), 2);
}
#[test]
fn finalize_sorts_by_severity_then_rule_number_then_location() {
let config = ValidateConfig::default();
let mut diags = vec![
make_diag("W3045", Severity::Warn, 10, 1),
make_diag("E3012", Severity::Error, 5, 1),
make_diag("F3012", Severity::Fatal, 5, 1),
];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags.len(), 3);
assert_eq!(diags[0].rule_id, "F3012");
assert_eq!(diags[1].rule_id, "E3012");
assert_eq!(diags[2].rule_id, "W3045");
}
#[test]
fn finalize_orders_same_severity_by_rule_number() {
let config = ValidateConfig::default();
let mut diags = vec![make_diag("E3999", Severity::Error, 1, 1), make_diag("E3012", Severity::Error, 50, 1)];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags[0].rule_id, "E3012", "lower rule number sorts first within a severity");
assert_eq!(diags[1].rule_id, "E3999");
}
#[test]
fn finalize_deduplicates_same_span_rule_message() {
let config = ValidateConfig::default();
let d = make_diag("E3012", Severity::Error, 5, 1);
let mut diags = vec![d.clone(), d];
let (total_before, suppressed) = finalize_diagnostics(&mut diags, &config);
assert_eq!(total_before, 2);
assert_eq!(suppressed, 1);
assert_eq!(diags.len(), 1);
}
#[test]
fn finalize_keeps_f_and_e_as_separate_diagnostics() {
let config = ValidateConfig::default();
let mut diags = vec![make_diag("F3012", Severity::Fatal, 5, 1), make_diag("E3012", Severity::Error, 5, 1)];
let (_, suppressed) = finalize_diagnostics(&mut diags, &config);
assert_eq!(suppressed, 0);
assert_eq!(diags.len(), 2);
assert_eq!(diags[0].rule_id, "F3012");
assert_eq!(diags[1].rule_id, "E3012");
}
#[test]
fn finalize_severity_filter_retains_fatal_always() {
let config = ValidateConfig { severity_level: Severity::Error, strict: false, ..Default::default() };
let mut diags = vec![
make_diag("F3012", Severity::Fatal, 1, 1),
make_diag("E3012", Severity::Error, 2, 1),
make_diag("W3045", Severity::Warn, 3, 1),
Diagnostic { severity: Severity::Info, message: "info".into(), location: None, ..default_diag() },
];
finalize_diagnostics(&mut diags, &config);
assert!(
diags.iter().all(|d| d.severity >= Severity::Error),
"all diagnostics should be Error or above after filtering"
);
assert_eq!(diags.len(), 2);
}
#[test]
fn finalize_different_messages_not_deduped() {
let config = ValidateConfig::default();
let mut d1 = make_diag("E3012", Severity::Error, 5, 1);
let mut d2 = make_diag("E3012", Severity::Error, 5, 1);
d1.message = "message A".into();
d2.message = "message B".into();
let mut diags = vec![d1, d2];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags.len(), 2);
}
#[test]
fn finalize_dedups_same_rule_message_across_sibling_with_different_message() {
let config = ValidateConfig::default();
let mut a1 = make_diag("W2506", Severity::Warn, 125, 1);
let mut sib = make_diag("W2506", Severity::Warn, 125, 1);
let mut a2 = make_diag("W2506", Severity::Warn, 125, 1);
a1.message = "param pWebServerAMI".into();
sib.message = "param pAppAmi".into();
a2.message = "param pWebServerAMI".into();
let mut diags = vec![a1, sib, a2];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags.len(), 2, "W2506 pWebServerAMI must dedup across sibling");
let msgs: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
assert!(msgs.contains(&"param pAppAmi"));
assert!(msgs.contains(&"param pWebServerAMI"));
}
#[test]
fn finalize_strict_upgrades_warnings_to_errors() {
let config = ValidateConfig { strict: true, ..Default::default() };
let mut diags = vec![
make_diag("W3045", Severity::Warn, 1, 1),
make_diag("E3012", Severity::Error, 2, 1),
make_diag("F3012", Severity::Fatal, 3, 1),
];
finalize_diagnostics(&mut diags, &config);
let severity_of = |id: &str| diags.iter().find(|d| d.rule_id == id).map(|d| d.severity);
assert_eq!(severity_of("W3045"), Some(Severity::Error), "Warn should be upgraded to Error");
assert_eq!(severity_of("E3012"), Some(Severity::Error), "Error should stay Error");
assert_eq!(severity_of("F3012"), Some(Severity::Fatal), "Fatal should stay Fatal");
}
#[test]
fn finalize_non_strict_preserves_warning_severity() {
let config = ValidateConfig { strict: false, ..Default::default() };
let mut diags = vec![make_diag("W3045", Severity::Warn, 1, 1), make_diag("E3012", Severity::Error, 2, 1)];
finalize_diagnostics(&mut diags, &config);
let severity_of = |id: &str| diags.iter().find(|d| d.rule_id == id).map(|d| d.severity);
assert_eq!(severity_of("W3045"), Some(Severity::Warn), "Warn should be preserved");
assert_eq!(severity_of("E3012"), Some(Severity::Error), "Error should stay Error");
}
#[test]
fn finalize_disable_builtin_rules_keeps_only_custom_and_guard() {
let config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
let mut diags = vec![
make_diag("F3006", Severity::Fatal, 1, 1),
Diagnostic { source: RuleOrigin::Custom, ..make_diag("CUSTOM001", Severity::Warn, 2, 1) },
Diagnostic { source: RuleOrigin::Guard, ..make_diag("GUARD001", Severity::Warn, 3, 1) },
make_diag("E3012", Severity::Error, 4, 1),
];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags.len(), 2);
assert_eq!(diags[0].rule_id, "CUSTOM001");
assert_eq!(diags[1].rule_id, "GUARD001");
}
#[test]
fn finalize_default_config_keeps_all_origins() {
let config = ValidateConfig::default();
let mut diags = vec![
make_diag("F3006", Severity::Fatal, 1, 1),
Diagnostic { source: RuleOrigin::Custom, ..make_diag("CUSTOM001", Severity::Warn, 2, 1) },
];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags.len(), 2);
}
#[test]
fn enrich_adds_phase_and_description() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "x".into(),
entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
category: Some(Category::Schema.as_str().into()),
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
assert_eq!(diags[0].phase, Some(Phase::Lint));
assert!(diags[0].rule_description.is_some(), "enriched diagnostic should have rule_description");
}
#[test]
fn enrich_parse_category_gets_parse_phase() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "F0000".into(),
severity: Severity::Fatal,
message: "dup".into(),
category: Some(Category::Structure.as_str().into()),
phase: Some(Phase::Parse),
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
assert_eq!(diags[0].phase, Some(Phase::Parse));
}
#[test]
fn enrich_fatal_rule_gets_schema_phase() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "F3012".into(),
severity: Severity::Fatal,
message: "x".into(),
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
assert_eq!(diags[0].phase, Some(Phase::Schema));
}
#[test]
fn enrich_custom_rule_gets_no_phase() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "Firewall".into(),
severity: Severity::Error,
message: "x".into(),
source: RuleOrigin::Custom,
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
assert_eq!(diags[0].phase, None, "custom rules must not be assigned a phase");
}
#[test]
fn enrich_full_format_builds_context() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "x".into(),
entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
property_path: Some("Properties.BucketName".into()),
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
assert!(diags[0].context.is_some(), "enriched diagnostic should have context");
}
#[test]
fn enrich_standard_format_skips_enrichment() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "x".into(),
entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
property_path: Some("Properties.BucketName".into()),
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Standard);
assert!(diags[0].phase.is_none(), "unenriched diagnostic should have no phase");
assert!(diags[0].rule_description.is_none(), "unenriched diagnostic should have no rule_description");
assert!(diags[0].context.is_none(), "unenriched diagnostic should have no context");
}
#[test]
fn make_resource_diagnostic_known_rule() {
let model = minimal_model();
let diag = make_resource_diagnostic(
"E3012",
"Type mismatch on BucketName",
&model,
"Bucket",
"Properties.BucketName",
Some("Use a string"),
);
assert_eq!(diag.rule_id, "E3012");
assert_eq!(diag.severity, Severity::Error);
assert_eq!(diag.resource_logical_id(), Some("Bucket"));
assert_eq!(diag.suggested_fix.as_deref(), Some("Use a string"));
}
#[test]
#[should_panic(expected = "Rule 'XBOGUS' not found in RULE_REGISTRY")]
fn make_resource_diagnostic_unknown_rule_panics() {
let model = minimal_model();
make_resource_diagnostic("XBOGUS", "msg", &model, "Bucket", "", None);
}
#[test]
fn make_resource_diagnostic_empty_resource_id() {
let model = minimal_model();
let diag = make_resource_diagnostic("E3012", "msg", &model, "", "", None);
assert!(diag.entity.is_none(), "diagnostic without resource_id should have no entity");
}
#[test]
fn build_context_no_resource_returns_none() {
let model = minimal_model();
assert!(build_context("E3012", None, "Properties.X", &model).is_none(), "no resource should return None");
}
#[test]
fn build_context_unknown_rule_returns_none() {
let model = minimal_model();
assert!(
build_context("XUNKNOWN", Some("Bucket"), "Properties.X", &model).is_none(),
"unknown rule should return None"
);
}
#[test]
fn build_context_e3012_with_property_path() {
let model = minimal_model();
let ctx = build_context("E3012", Some("Bucket"), "Properties.BucketName", &model)
.expect("E3012 with property path should return context");
assert!(ctx.actual_value.is_some(), "context should have actual_value");
}
#[test]
fn build_context_runtime_rules_carry_actual_value_and_lifecycle() {
let yaml = br#"
Resources:
Fn:
Type: AWS::Lambda::Function
Properties:
Runtime: nodejs4.3
"#;
let model = SemanticModel::from_bytes(yaml).expect("model with lambda runtime");
for (rule_id, expected_lifecycle) in
[("E2533", "end-of-life"), ("E2531", "create-blocked"), ("W2531", "deprecated")]
{
let ctx = build_context(rule_id, Some("Fn"), "Properties.Runtime", &model)
.unwrap_or_else(|| panic!("{rule_id} should return context"));
assert_eq!(
ctx.actual_value.as_ref().and_then(|v| v.as_str()),
Some("nodejs4.3"),
"{rule_id} context should carry the offending runtime string"
);
assert_eq!(ctx.lifecycle.as_deref(), Some(expected_lifecycle), "{rule_id} lifecycle marker");
}
}
#[test]
fn build_context_runtime_omits_actual_value_when_branches_disagree() {
let yaml = br#"
Parameters:
Env:
Type: String
Conditions:
IsProd: !Equals [!Ref Env, prod]
Resources:
Fn:
Type: AWS::Lambda::Function
Properties:
Runtime: !If [IsProd, dotnetcore2.1, nodejs4.3]
"#;
let model = SemanticModel::from_bytes(yaml).expect("model with conditional runtime");
let ctx = build_context("E2533", Some("Fn"), "Properties.Runtime", &model)
.expect("E2533 should still return context (lifecycle)");
assert_eq!(ctx.lifecycle.as_deref(), Some("end-of-life"), "lifecycle is unconditionally safe");
assert!(
ctx.actual_value.is_none(),
"actual_value must be omitted when branches resolve to different runtimes, got {:?}",
ctx.actual_value
);
}
#[test]
fn backfill_locations_only_fills_missing_and_resolves_resource_property() {
let yaml = br#"
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: my-bucket
"#;
let model = SemanticModel::from_bytes(yaml).expect("model");
let existing = SourceSpan { start_line: 999, start_column: 1, end_line: 999, end_column: 2 };
let mut diags = vec![
Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "x".into(),
entity: Entity::resource("Bucket", None),
property_path: Some("Properties.BucketName".into()),
..default_diag()
},
Diagnostic {
rule_id: "E3013".into(),
severity: Severity::Error,
message: "y".into(),
location: Some(existing),
..default_diag()
},
];
backfill_locations(&mut diags, &model);
assert!(diags[0].location.is_some(), "missing location should be backfilled");
assert_ne!(diags[0].location, Some(UNKNOWN_SPAN), "backfilled location must be a real span");
assert_eq!(diags[1].location, Some(existing), "existing location must be preserved");
}
#[test]
fn backfill_entities_derives_from_entity_paths_without_overriding() {
let model = minimal_model();
let mut diags = vec![
Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "x".into(),
entity: Entity::resource("Bucket", None),
..default_diag()
},
Diagnostic {
rule_id: "W2001".into(),
severity: Severity::Warn,
message: "y".into(),
property_path: Some("Parameters/MyParam".into()),
..default_diag()
},
Diagnostic { rule_id: "F0001".into(), severity: Severity::Fatal, message: "z".into(), ..default_diag() },
];
backfill_entities(&mut diags, &model);
let resource = diags[0].entity.as_ref().expect("resource entity preserved");
assert_eq!(resource.logical_id, "Bucket");
assert_eq!(resource.entity_type, EntityType::Resource);
assert_eq!(
resource.resource_type.as_deref(),
Some("AWS::S3::Bucket"),
"missing resource type is filled from the model"
);
let parameter = diags[1].entity.as_ref().expect("parameter entity derived from path");
assert_eq!(parameter.logical_id, "MyParam");
assert_eq!(parameter.entity_type, EntityType::Parameter);
assert_eq!(parameter.resource_type, None);
assert!(diags[2].entity.is_none(), "template-level finding has no entity");
}
#[test]
fn build_context_e3012_empty_path_returns_none() {
let model = minimal_model();
let ctx = build_context("E3012", Some("Bucket"), "", &model);
assert!(ctx.is_none(), "E3012 with empty path should return None");
}
#[test]
fn build_context_f3002_extracts_property_name() {
let model = minimal_model();
let ctx = build_context("F3002", Some("Bucket"), "Properties.BucketName", &model)
.expect("F3002 should return context");
assert_eq!(ctx.property.as_deref(), Some("BucketName"));
}
#[test]
fn build_context_w3042_sets_deprecated_lifecycle() {
let model = minimal_model();
let ctx = build_context("W9009", Some("Bucket"), "", &model).expect("W9009 should return context");
assert_eq!(ctx.lifecycle.as_deref(), Some("deprecated"));
}
#[test]
fn build_context_i3043_sets_create_only_lifecycle() {
let model = minimal_model();
let ctx = build_context("I9001", Some("Bucket"), "", &model).expect("I9001 should return context");
assert_eq!(ctx.lifecycle.as_deref(), Some("create-only"));
}
#[test]
fn build_context_w9054_sets_write_only_lifecycle() {
let model = minimal_model();
let ctx = build_context("W9054", Some("Bucket"), "", &model).expect("W9054 should return context");
assert_eq!(ctx.lifecycle.as_deref(), Some("write-only"));
}
#[test]
fn parse_diagnostic_debug_severity() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "D9999",
"severity": Severity::Debug.as_str(),
"message": "dbg"
});
let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
assert_eq!(diag.severity, Severity::Debug);
}
#[test]
fn parse_diagnostic_no_resource_no_location_resolves_via_section_span() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "F0001",
"severity": Severity::Fatal.as_str(),
"message": "no resources"
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
assert_eq!(diag.rule_id, "F0001");
}
#[test]
fn parse_diagnostic_builtin_rule_category_comes_from_registry() {
let model = minimal_model();
let val = serde_json::json!({
"rule_id": "E3012",
"severity": Severity::Error.as_str(),
"message": "x"
});
let diag = parse_diagnostic(&val, &model, None).unwrap();
let expected = lookup_rule("E3012").unwrap().category.as_str();
assert_eq!(diag.category.as_deref(), Some(expected));
}
#[test]
fn build_context_e3030_resolves_actual_value() {
let model = minimal_model();
let ctx = build_context("E3030", Some("Bucket"), "Properties.BucketName", &model)
.expect("E3030 should return context");
assert!(ctx.actual_value.is_some(), "E3030 context should have actual_value");
}
#[test]
fn build_context_f3006_adds_resource_type() {
let model = minimal_model();
let ctx = build_context("F3006", Some("Bucket"), "", &model).expect("F3006 should return context");
let extra = ctx.extra.unwrap();
assert_eq!(extra.get("resource_type").and_then(|v| v.as_str()), Some("AWS::S3::Bucket"));
}
#[test]
fn build_context_f3003_noop_returns_none() {
let model = minimal_model();
assert!(build_context("F3003", Some("Bucket"), "", &model).is_none(), "F3003 should return None");
}
#[test]
fn build_context_e3053_with_property_path() {
let model = minimal_model();
let ctx = build_context("E3053", Some("Bucket"), "Properties.BucketName", &model)
.expect("E3053 should return context");
assert!(ctx.actual_value.is_some(), "E3053 context should have actual_value");
}
#[test]
fn build_context_e3053_empty_path_returns_none() {
let model = minimal_model();
assert!(
build_context("E3053", Some("Bucket"), "", &model).is_none(),
"E3053 with empty path should return None"
);
}
#[test]
fn build_context_e2501_with_condition() {
let yaml = br#"
AWSTemplateFormatVersion: "2010-09-09"
Conditions:
IsProd:
!Equals [!Ref "AWS::Region", "us-east-1"]
Resources:
Bucket:
Type: AWS::S3::Bucket
Condition: IsProd
"#;
let model = SemanticModel::from_bytes(yaml).expect("model with condition");
let ctx =
build_context("W2503", Some("Bucket"), "", &model).expect("W2503 with condition should return context");
let extra = ctx.extra.unwrap();
assert!(extra.contains_key("source_condition"));
}
#[test]
fn build_context_e2501_without_condition_returns_none() {
let model = minimal_model();
assert!(
build_context("W2503", Some("Bucket"), "", &model).is_none(),
"W2503 without condition should return None"
);
}
#[test]
fn enrich_preserves_existing_phase_and_description() {
let model = minimal_model();
let meta = meta_map();
let mut diags = vec![Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "x".into(),
phase: Some(Phase::Lint),
rule_description: Some("custom desc".into()),
..default_diag()
}];
enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Standard);
assert_eq!(diags[0].phase, Some(Phase::Lint));
assert_eq!(diags[0].rule_description.as_deref(), Some("custom desc"));
}
#[test]
fn finalize_warning_level_filters_info() {
let config = ValidateConfig { severity_level: Severity::Warn, ..Default::default() };
let mut diags = vec![
make_diag("F3012", Severity::Fatal, 1, 1),
make_diag("E3012", Severity::Error, 2, 1),
make_diag("W3045", Severity::Warn, 3, 1),
Diagnostic { severity: Severity::Info, message: "info".into(), location: None, ..default_diag() },
];
finalize_diagnostics(&mut diags, &config);
assert!(
diags.iter().all(|d| d.severity >= Severity::Warn),
"all diagnostics should be Warn or above after filtering"
);
assert_eq!(diags.len(), 3);
}
#[test]
fn finalize_different_property_paths_not_deduped() {
let config = ValidateConfig::default();
let mut d1 = make_diag("E3012", Severity::Error, 5, 1);
let mut d2 = make_diag("E3012", Severity::Error, 5, 1);
d1.property_path = Some("Properties.A".into());
d2.property_path = Some("Properties.B".into());
let mut diags = vec![d1, d2];
finalize_diagnostics(&mut diags, &config);
assert_eq!(diags.len(), 2);
}
#[test]
fn build_rule_list_uses_stored_severity_for_any_rule_id() {
let mut meta = HashMap::new();
meta.insert(
"X9999".into(),
RuleMetadataEntry {
category: Some("custom".into()),
description: "desc".into(),
severity: Severity::Error,
origin: RuleOrigin::Engine,
},
);
let rules = build_rule_list(&meta, &HashMap::new());
assert_eq!(rules[0].severity, Severity::Error);
}
#[test]
fn engine_type_default_is_composite() {
assert_eq!(EngineType::default(), EngineType::Composite);
}
#[test]
fn engine_type_as_str_and_display_name_every_variant() {
for (engine, expected) in
[(EngineType::Rego, "Rego"), (EngineType::Cel, "CEL"), (EngineType::Composite, "Composite")]
{
assert_eq!(engine.as_str(), expected);
assert_eq!(engine.to_string(), expected, "Display must match as_str");
}
}
#[test]
fn engine_type_parse_accepts_case_insensitive_input() {
assert_eq!(EngineType::parse("rego"), Ok(EngineType::Rego));
assert_eq!(EngineType::parse("REGO"), Ok(EngineType::Rego));
assert_eq!(EngineType::parse("Cel"), Ok(EngineType::Cel));
assert_eq!(EngineType::parse("CEL"), Ok(EngineType::Cel));
assert_eq!(EngineType::parse("composite"), Ok(EngineType::Composite));
assert_eq!(EngineType::parse("COMPOSITE"), Ok(EngineType::Composite));
}
#[test]
fn engine_type_parse_returns_error_for_unknown_value() {
let error =
EngineType::parse("unknown").expect_err("an unknown engine selector must return an error, not a default");
assert!(
error.contains("Unknown engine type 'unknown'")
&& error.contains("rego")
&& error.contains("cel")
&& error.contains("composite"),
"the error must name the bad selector and all three valid options, got: {error}"
);
}
#[test]
fn catch_panics_maps_panic_to_caller_error_type() {
#[derive(Debug, PartialEq)]
struct BindingError(String);
let result: Result<(), BindingError> = catch_panics(|| panic!("engine init blew up"), BindingError);
let error = result.expect_err("a panic must be converted to the caller's error type");
assert!(error.0.contains("engine init blew up"), "the panic payload must be carried, got: {}", error.0);
}
#[test]
fn catch_panics_passes_through_ok_and_err_without_wrapping() {
let ok: Result<i32, String> = catch_panics(|| Ok(7), |m| m);
assert_eq!(ok, Ok(7), "a non-panicking Ok must pass through unchanged");
let err: Result<i32, String> = catch_panics(|| Err("plain error".to_string()), |_| "panic".to_string());
assert_eq!(err, Err("plain error".to_string()), "a returned Err must pass through, not be treated as a panic");
}
#[test]
fn validate_catching_panics_converts_panic_to_structured_error() {
let error = validate_catching_panics(|| panic!("simulated invariant violation"))
.expect_err("a panic must be converted into a structured error, not propagated");
match error {
ValidationError::Engine(message) => assert!(
message.contains("Internal validation error") && message.contains("simulated invariant violation"),
"error must wrap the panic payload as a structured engine error, got: {message}"
),
other => panic!("expected ValidationError::Engine, got {other:?}"),
}
}
#[test]
fn validate_catching_panics_passes_success_through_unchanged() {
let model = minimal_model();
let report = validate_catching_panics(|| {
Ok(build_report_with_versions(
vec![],
&model,
0,
Some(7),
false,
Severity::Info,
"inline".to_string(),
TEST_CFN_LINT_VERSION.to_string(),
TEST_RESOURCE_SCHEMA_VERSION.to_string(),
))
})
.expect("a non-panicking Ok result must pass through the guard unchanged");
assert_eq!(report.metadata.rules_evaluated, 7, "the guard must return the closure's report verbatim");
}
enum Explosion {
Panic,
ReturnErr,
}
struct ExplodingEngine {
mode: Explosion,
metadata: HashMap<String, RuleMetadataEntry>,
metric: PhaseMetric,
}
impl ExplodingEngine {
fn new(mode: Explosion) -> Self {
Self { mode, metadata: HashMap::new(), metric: PhaseMetric { duration_ms: 0.0 } }
}
}
impl ValidationEngine for ExplodingEngine {
fn engine_name(&self) -> &str {
"exploding-test-engine"
}
fn evaluate_rules(
&self,
_model: &Arc<SemanticModel>,
_config: &ValidateConfig,
) -> Result<Vec<Diagnostic>, ValidationError> {
match self.mode {
Explosion::Panic => panic!("simulated engine invariant violation"),
Explosion::ReturnErr => Err(ValidationError::Engine("Simulated engine evaluation failure".to_string())),
}
}
fn list_rules(&self) -> Vec<RuleInfo> {
Vec::new()
}
fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
&self.metadata
}
fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
HashMap::new()
}
fn init_metric(&self) -> &PhaseMetric {
&self.metric
}
}
const WELL_FORMED_TEMPLATE: &[u8] = b"Resources:\n Bucket:\n Type: AWS::S3::Bucket\n";
struct EnrichmentTrackingEngine {
external_metadata_requests: AtomicUsize,
metadata: HashMap<String, RuleMetadataEntry>,
metric: PhaseMetric,
}
impl EnrichmentTrackingEngine {
fn new() -> Self {
Self {
external_metadata_requests: AtomicUsize::new(0),
metadata: build_rule_metadata_map(),
metric: PhaseMetric { duration_ms: 0.0 },
}
}
}
impl ValidationEngine for EnrichmentTrackingEngine {
fn engine_name(&self) -> &str {
"enrichment-tracking-engine"
}
fn evaluate_rules(
&self,
_model: &Arc<SemanticModel>,
_config: &ValidateConfig,
) -> Result<Vec<Diagnostic>, ValidationError> {
Ok(vec![Diagnostic {
rule_id: "E3012".into(),
severity: Severity::Error,
message: "tracking diagnostic".into(),
..default_diag()
}])
}
fn list_rules(&self) -> Vec<RuleInfo> {
Vec::new()
}
fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
&self.metadata
}
fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
self.external_metadata_requests.fetch_add(1, Ordering::SeqCst);
HashMap::new()
}
fn init_metric(&self) -> &PhaseMetric {
&self.metric
}
}
#[test]
fn standard_detail_level_bypasses_enrichment_metadata_lookup() {
let schema_validator = SchemaValidator::default();
let standard_engine = EnrichmentTrackingEngine::new();
let standard_report = validate_bytes(
&standard_engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { detail_level: DetailLevel::Standard, ..Default::default() },
)
.expect("STANDARD validation must succeed");
assert_eq!(
standard_engine.external_metadata_requests.load(Ordering::SeqCst),
0,
"STANDARD must not request metadata used only by diagnostic enrichment"
);
let standard_diagnostic = standard_report
.diagnostics
.iter()
.find(|diagnostic| diagnostic.message == "tracking diagnostic")
.expect("the tracking diagnostic must be present");
assert!(standard_diagnostic.phase.is_none());
assert!(standard_diagnostic.rule_description.is_none());
assert!(standard_diagnostic.context.is_none());
let detailed_engine = EnrichmentTrackingEngine::new();
let detailed_report = validate_bytes(
&detailed_engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { detail_level: DetailLevel::Detailed, ..Default::default() },
)
.expect("DETAILED validation must succeed");
assert_eq!(
detailed_engine.external_metadata_requests.load(Ordering::SeqCst),
1,
"DETAILED must request external metadata for diagnostic enrichment"
);
let detailed_diagnostic = detailed_report
.diagnostics
.iter()
.find(|diagnostic| diagnostic.message == "tracking diagnostic")
.expect("the tracking diagnostic must be present");
assert_eq!(detailed_diagnostic.phase, Some(Phase::Lint));
assert!(detailed_diagnostic.rule_description.is_some());
}
struct BudgetExhaustingEngine {
kinds: Vec<template_model::BudgetKind>,
metadata: HashMap<String, RuleMetadataEntry>,
metric: PhaseMetric,
}
impl BudgetExhaustingEngine {
fn new(kinds: Vec<template_model::BudgetKind>) -> Self {
Self { kinds, metadata: build_rule_metadata_map(), metric: PhaseMetric { duration_ms: 0.0 } }
}
}
impl ValidationEngine for BudgetExhaustingEngine {
fn engine_name(&self) -> &str {
"budget-test-engine"
}
fn evaluate_rules(
&self,
model: &Arc<SemanticModel>,
_config: &ValidateConfig,
) -> Result<Vec<Diagnostic>, ValidationError> {
for kind in &self.kinds {
model.record_budget_exhaustion(*kind);
}
Ok(Vec::new())
}
fn list_rules(&self) -> Vec<RuleInfo> {
Vec::new()
}
fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
&self.metadata
}
fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
HashMap::new()
}
fn init_metric(&self) -> &PhaseMetric {
&self.metric
}
}
#[test]
fn budget_exhaustion_emits_one_warning_with_metadata() {
use template_model::BudgetKind;
let engine = BudgetExhaustingEngine::new(vec![
BudgetKind::ResolverDepth,
BudgetKind::EnumExpansion,
BudgetKind::ResolverDepth, ]);
let schema_validator = SchemaValidator::default();
let report = validate_bytes(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { detail_level: DetailLevel::Detailed, ..Default::default() },
)
.expect("validation must succeed");
let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
assert_eq!(budget_warnings.len(), 1, "exactly one aggregate budget warning must be emitted");
let w = budget_warnings[0];
assert_eq!(w.severity, Severity::Warn);
assert_eq!(
w.rule_description.as_deref(),
Some("Deterministic validation budgets were exhausted; see report metadata for completeness impact")
);
assert_eq!(w.category.as_deref(), Some(Category::Structure.as_str()));
assert_eq!(w.source, RuleOrigin::Engine);
assert_eq!(w.phase, Some(Phase::Lint));
assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
let budget_exhaustions =
report.metadata.budget_exhaustions.as_deref().expect("exhausted budgets must be present in metadata");
assert_eq!(budget_exhaustions.len(), 2, "two distinct kinds, not three");
assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
assert_eq!(budget_exhaustions[0].description, BudgetKind::ResolverDepth.description());
assert_eq!(budget_exhaustions[0].limit, BudgetKind::ResolverDepth.limit());
assert_eq!(budget_exhaustions[1].kind, "enumExpansion");
assert_eq!(budget_exhaustions[1].description, BudgetKind::EnumExpansion.description());
assert!(w.context.is_some(), "budget warning context must be attached at the DETAILED detail level");
let ctx = w.context.as_ref().unwrap();
let extra = ctx.extra.as_ref().expect("context.extra must be populated");
assert!(extra.contains_key("budgetExhaustions"));
}
#[test]
fn standard_detail_level_skips_budget_warning_enrichment() {
use template_model::BudgetKind;
let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
let schema_validator = SchemaValidator::default();
let report = validate_bytes(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { detail_level: DetailLevel::Standard, ..Default::default() },
)
.expect("STANDARD validation must succeed");
let warning = report
.diagnostics
.iter()
.find(|diagnostic| diagnostic.rule_id == "W9052")
.expect("budget exhaustion must still emit its aggregate warning");
assert!(warning.phase.is_none());
assert!(warning.context.is_none());
let budget_exhaustions = report
.metadata
.budget_exhaustions
.as_deref()
.expect("budget metadata must remain available without diagnostic enrichment");
assert_eq!(budget_exhaustions.len(), 1);
assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
}
#[test]
fn context_only_required_property_combinations_keeps_report_status_ok() {
use template_model::BudgetKind;
let choice_count = BudgetKind::RequiredPropertyCombinations.limit() as usize + 1;
let choices: Vec<String> = (0..choice_count).map(|index| format!("Choice{index:03}")).collect();
let properties: serde_json::Map<String, serde_json::Value> =
choices.iter().map(|choice| (choice.clone(), serde_json::json!({"type": "string"}))).collect();
let schema_validator = SchemaValidator::try_with_additional_schemas(vec![(
"AWS::Test::RequiredCombinationBudget",
serde_json::json!({
"properties": properties,
"anyOf": [{"requiredOr": choices}],
"additionalProperties": false
}),
)])
.expect("test schema must compile");
let template = b"Resources:\n R:\n Type: AWS::Test::RequiredCombinationBudget\n Properties: {}\n";
let engine = BudgetExhaustingEngine::new(Vec::new());
let report = validate_bytes(&engine, &schema_validator, template, ValidateConfig::default())
.expect("validation must succeed");
let budget_warnings: Vec<_> =
report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "W9052").collect();
assert_eq!(budget_warnings.len(), 1);
assert_eq!(report.status, ReportStatus::Ok);
let budget_exhaustions =
report.metadata.budget_exhaustions.as_deref().expect("context-only exhaustion must be present in metadata");
assert_eq!(budget_exhaustions.len(), 1);
assert_eq!(budget_exhaustions[0].kind, "requiredPropertyCombinations");
assert_eq!(budget_exhaustions[0].description, BudgetKind::RequiredPropertyCombinations.description());
assert_eq!(budget_exhaustions[0].limit, BudgetKind::RequiredPropertyCombinations.limit());
assert!(!budget_exhaustions[0].analysis_incomplete);
}
#[test]
fn severity_filtering_removes_warning_but_metadata_persists() {
use template_model::BudgetKind;
let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
let schema_validator = SchemaValidator::default();
let report = validate_bytes(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { severity_level: Severity::Error, ..Default::default() },
)
.expect("validation must succeed");
let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
assert!(budget_warnings.is_empty(), "severity filter must remove the warning");
assert_eq!(
report.status,
ReportStatus::AnalysisIncomplete,
"status reflects correctness-affecting exhaustion regardless of filter"
);
assert!(report.metadata.budget_exhaustions.is_some());
}
#[test]
fn parse_error_sets_error_status_without_budget_warning() {
let schema_validator = SchemaValidator::default();
let engine = BudgetExhaustingEngine::new(vec![]);
let report = validate_bytes_with_path(
&engine,
&schema_validator,
b"{ not valid yaml or json <<<",
ValidateConfig::default(),
"bad.yaml".to_string(),
)
.expect("parse errors return Ok with error diagnostics");
assert_eq!(report.status, ReportStatus::Error);
assert!(report.metadata.budget_exhaustions.is_none());
let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
assert!(budget_warnings.is_empty(), "parse errors must not emit a budget warning");
}
#[test]
fn category_filtering_removes_warning_but_metadata_persists() {
use template_model::BudgetKind;
let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
let schema_validator = SchemaValidator::default();
let filters = FilterConfig::new(
RuleFilterConfig::default(),
RuleFilterConfig {
categories: vec![Category::Structure.as_str().to_string()],
..RuleFilterConfig::default()
},
);
let report = validate_bytes(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { filters, ..ValidateConfig::default() },
)
.expect("validation must succeed");
assert!(report.diagnostics.iter().all(|diagnostic| diagnostic.rule_id != "W9052"));
assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
let budget_exhaustions = report
.metadata
.budget_exhaustions
.as_deref()
.expect("exhausted budget must be present despite warning suppression");
assert_eq!(budget_exhaustions.len(), 1);
assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
}
#[test]
fn disabling_builtins_suppresses_warning_but_metadata_persists() {
use template_model::BudgetKind;
let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
let schema_validator = SchemaValidator::default();
let report = validate_bytes(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig { disable_builtin_rules: true, ..ValidateConfig::default() },
)
.expect("validation must succeed");
assert!(report.diagnostics.iter().all(|diagnostic| diagnostic.rule_id != "W9052"));
assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
let budget_exhaustions = report
.metadata
.budget_exhaustions
.as_deref()
.expect("exhausted budget must be present despite warning suppression");
assert_eq!(budget_exhaustions.len(), 1);
assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
}
#[test]
fn non_curtailed_fixture_omits_exhaustions_and_has_ok_status() {
let engine = BudgetExhaustingEngine::new(vec![]); let schema_validator = SchemaValidator::default();
let report = validate_bytes(&engine, &schema_validator, WELL_FORMED_TEMPLATE, ValidateConfig::default())
.expect("validation must succeed");
assert_eq!(report.status, ReportStatus::Ok);
assert!(report.metadata.budget_exhaustions.is_none());
let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
assert!(budget_warnings.is_empty());
}
#[test]
fn engine_exception_surfaces_as_error_never_as_diagnostic() {
let schema_validator = SchemaValidator::default();
let engine = ExplodingEngine::new(Explosion::ReturnErr);
let result = validate_bytes_with_path(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig::default(),
"inline".to_string(),
);
match result {
Err(ValidationError::Engine(message)) => assert!(
message.contains("Simulated engine evaluation failure"),
"an engine exception must surface verbatim as an error, got: {message}"
),
Ok(report) => panic!(
"an engine exception must surface as Err, never as an Ok report with \
diagnostics; got {} diagnostics",
report.diagnostics.len()
),
Err(other) => panic!("expected ValidationError::Engine, got {other:?}"),
}
}
#[test]
fn engine_panic_is_caught_as_error_never_as_diagnostic() {
let schema_validator = SchemaValidator::default();
let engine = ExplodingEngine::new(Explosion::Panic);
let result = validate_catching_panics(|| {
validate_bytes_with_path(
&engine,
&schema_validator,
WELL_FORMED_TEMPLATE,
ValidateConfig::default(),
"inline".to_string(),
)
});
match result {
Err(ValidationError::Engine(message)) => assert!(
message.contains("Internal validation error")
&& message.contains("simulated engine invariant violation"),
"a panic must surface as a structured internal error, got: {message}"
),
Ok(report) => panic!(
"a panic must surface as Err, never as an Ok report with diagnostics; got {} \
diagnostics",
report.diagnostics.len()
),
Err(other) => panic!("expected ValidationError::Engine, got {other:?}"),
}
}
}