1use crate::message::{quote, render_str_list};
22use crate::model::SemanticModel;
23use crate::regions::AWS_REGIONS;
24use crate::resolved_value::resolved_value_at_path;
25use crate::resolver::ResolvedValue;
26use std::collections::{BTreeSet, HashSet};
27
28pub const REGION_SCOPED_TYPE_PATHS: &[(&str, &str)] = &[
35 ("AWS::EC2::Instance", "Properties.InstanceType"),
36 ("AWS::GameLift::Fleet", "Properties.EC2InstanceType"),
37 ("AWS::EMR::InstanceTypeConfig", "Properties.InstanceType"),
38 ("AWS::EMR::InstanceFleetConfig", "Properties.InstanceType"),
39 ("AWS::ManagedBlockchain::Node", "Properties.NodeConfiguration.InstanceType"),
40 ("AWS::DocDB::DBInstance", "Properties.DBInstanceClass"),
41 ("AWS::AppStream::Fleet", "Properties.InstanceType"),
42 ("AWS::ElastiCache::CacheCluster", "Properties.CacheNodeType"),
43 ("AWS::DAX::Cluster", "Properties.NodeType"),
44 ("AWS::Neptune::DBInstance", "Properties.DBInstanceClass"),
45 ("AWS::Redshift::Cluster", "Properties.NodeType"),
46 ("AWS::AmazonMQ::Broker", "Properties.HostInstanceType"),
47 ("AWS::RDS::DBInstance", "Properties.DBInstanceClass"),
48 ("AWS::RDS::DBCluster", "Properties.DBClusterInstanceClass"),
49 ("AWS::SageMaker::DataQualityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
50 ("AWS::SageMaker::ModelBiasJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
51 ("AWS::SageMaker::ModelExplainabilityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
52 ("AWS::SageMaker::ModelQualityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
53 (
54 "AWS::SageMaker::MonitoringSchedule",
55 "Properties.MonitoringScheduleConfig.MonitoringJobDefinition.MonitoringResources.ClusterConfig.InstanceType",
56 ),
57 ("AWS::Elasticsearch::Domain", "Properties.ElasticsearchClusterConfig.InstanceType"),
58 ("AWS::OpenSearchService::Domain", "Properties.ClusterConfig.InstanceType"),
59 (
60 "AWS::SageMaker::InferenceExperiment",
61 "Properties.ModelVariants.{}.InfrastructureConfig.RealTimeInferenceConfig.InstanceType",
62 ),
63 (
64 "AWS::SageMaker::ModelPackage",
65 "Properties.ValidationSpecification.ValidationProfiles.{}.TransformJobDefinition.TransformResources.InstanceType",
66 ),
67 ("AWS::SageMaker::Cluster", "Properties.InstanceGroups.{}.InstanceType"),
68 ("AWS::SageMaker::Cluster", "Properties.RestrictedInstanceGroups.{}.InstanceType"),
69];
70
71pub fn template_has_region_scoped_value(model: &SemanticModel) -> bool {
78 REGION_SCOPED_TYPE_PATHS.iter().any(|(rtype, path)| {
79 model.resources_of_type(rtype).iter().any(|rid| resolves_to_concrete_string(model, rid, path))
80 })
81}
82
83fn resolves_to_concrete_string(model: &SemanticModel, rid: &str, path: &str) -> bool {
87 let Some(resolved) = resolve_for_detection(model, rid, path) else {
88 return false;
89 };
90 any_concrete_string(&resolved)
91}
92
93fn resolve_for_detection(model: &SemanticModel, rid: &str, path: &str) -> Option<ResolvedValue> {
95 if path.contains("{}") {
96 let resource = model.resources.get(rid)?;
97 let prop_path = path.strip_prefix("Properties.").unwrap_or(path);
98 let mut segments = prop_path.splitn(2, '.');
99 let top_key = segments.next()?;
100 let resolved = resource.properties.get(top_key)?;
101 return match segments.next() {
102 Some(rest) if !rest.is_empty() => resolved_value_at_path(resolved, rest),
103 _ => Some(resolved.clone()),
104 };
105 }
106 model.resolve_deep(rid, path).or_else(|| model.resolve(rid, path).cloned())
107}
108
109fn any_concrete_string(value: &ResolvedValue) -> bool {
113 match value {
114 ResolvedValue::Concrete { value } => value.0.is_string(),
115 ResolvedValue::List { items } | ResolvedValue::Enum { variants: items } => {
116 items.iter().any(any_concrete_string)
117 }
118 ResolvedValue::Conditional { if_true, if_false, .. } => {
119 any_concrete_string(if_true) || any_concrete_string(if_false)
120 }
121 _ => false,
122 }
123}
124
125pub const ANY_REGION_LABEL: &str = "any region";
128
129pub fn region_label(region: Option<&str>) -> &str {
132 region.unwrap_or(ANY_REGION_LABEL)
133}
134
135pub fn flat_invalid_message(value: &str, region: Option<&str>) -> String {
140 match region {
141 Some(region) => format!("'{value}' is not valid for region '{region}'"),
142 None => format!("'{value}' is not valid in any region"),
143 }
144}
145
146pub fn conditional_invalid_message(value: &str, allowed_sorted: &[String], region: Option<&str>) -> String {
151 let rendered = render_str_list(allowed_sorted);
152 match region {
153 Some(region) => format!("'{value}' is not one of {rendered} in '{region}'"),
154 None => format!("'{value}' is not one of {rendered} in any region"),
155 }
156}
157
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct ConditionalEnumMismatch {
160 allowed_values: Vec<String>,
161 property_constraints: Vec<(String, String)>,
162}
163
164#[derive(Clone, Debug)]
165struct ConditionalBranchEnum {
166 allowed_values: HashSet<String>,
167 property_constraints: Vec<(String, String)>,
168}
169
170pub fn conditional_mismatch_message(value: &str, mismatch: &ConditionalEnumMismatch, region: Option<&str>) -> String {
174 if mismatch.property_constraints.is_empty() {
175 return conditional_invalid_message(value, &mismatch.allowed_values, region);
176 }
177
178 let rendered_allowed = render_str_list(&mismatch.allowed_values);
179 let rendered_constraints = mismatch
180 .property_constraints
181 .iter()
182 .map(|(property, expected)| format!("{property} is {}", quote(expected)))
183 .collect::<Vec<_>>()
184 .join(" and ");
185 match region {
186 Some(region) => format!("'{value}' is not one of {rendered_allowed} when {rendered_constraints} in '{region}'"),
187 None => format!("'{value}' is not one of {rendered_allowed} when {rendered_constraints} in any region"),
188 }
189}
190
191pub fn flat_allowed_value_set<'a>(
198 doc: &'a serde_json::Map<String, serde_json::Value>,
199 region: Option<&str>,
200) -> Option<HashSet<&'a str>> {
201 match region {
202 Some(region) => {
203 let values = doc.get(region)?.get("enum")?.as_array()?;
204 Some(values.iter().filter_map(|v| v.as_str()).collect())
205 }
206 None => {
207 let mut union = HashSet::new();
208 for region in AWS_REGIONS {
209 if let Some(values) = doc.get(*region).and_then(|r| r.get("enum")).and_then(|e| e.as_array()) {
210 union.extend(values.iter().filter_map(|v| v.as_str()));
211 }
212 }
213 (!union.is_empty()).then_some(union)
218 }
219 }
220}
221
222pub fn flat_allowed_values<'a>(
224 doc: &'a serde_json::Map<String, serde_json::Value>,
225 region: Option<&str>,
226) -> Option<BTreeSet<&'a str>> {
227 flat_allowed_value_set(doc, region).map(|allowed| allowed.into_iter().collect())
228}
229
230pub fn conditional_invalid_enum<F>(
241 doc: &serde_json::Map<String, serde_json::Value>,
242 region: Option<&str>,
243 target_prop: &str,
244 normalize_engine_case: bool,
245 value: &str,
246 resolve_prop: F,
247) -> Option<Vec<String>>
248where
249 F: Fn(&str) -> Option<String>,
250{
251 conditional_enum_mismatch(doc, region, target_prop, normalize_engine_case, value, resolve_prop)
252 .map(|mismatch| mismatch.allowed_values)
253}
254
255pub fn conditional_enum_mismatch<F>(
259 doc: &serde_json::Map<String, serde_json::Value>,
260 region: Option<&str>,
261 target_prop: &str,
262 normalize_engine_case: bool,
263 value: &str,
264 resolve_prop: F,
265) -> Option<ConditionalEnumMismatch>
266where
267 F: Fn(&str) -> Option<String>,
268{
269 match region {
270 Some(region) => {
271 let region_doc = doc.get(region)?;
272 let branch_enums = conditional_branch_enums(region_doc, target_prop, normalize_engine_case, &resolve_prop);
273 invalid_branch_mismatch(&branch_enums, value)
274 }
275 None => {
276 let mut valid_somewhere = false;
277 let mut had_matching_branch = false;
278 let mut failing_largest: Option<ConditionalEnumMismatch> = None;
279 for region in AWS_REGIONS {
280 let Some(region_doc) = doc.get(*region) else {
281 continue;
282 };
283 let branch_enums =
284 conditional_branch_enums(region_doc, target_prop, normalize_engine_case, &resolve_prop);
285 if branch_enums.is_empty() {
286 continue;
287 }
288 had_matching_branch = true;
289 if branch_enums.iter().all(|branch| branch.allowed_values.contains(value)) {
290 valid_somewhere = true;
291 break;
292 }
293 if let Some(mismatch) = invalid_branch_mismatch(&branch_enums, value)
294 && failing_largest
295 .as_ref()
296 .is_none_or(|current| mismatch.allowed_values.len() > current.allowed_values.len())
297 {
298 failing_largest = Some(mismatch);
299 }
300 }
301 if !had_matching_branch || valid_somewhere {
302 return None;
303 }
304 failing_largest
305 }
306 }
307}
308
309fn conditional_branch_enums<F>(
314 region_doc: &serde_json::Value,
315 target_prop: &str,
316 normalize_engine_case: bool,
317 resolve_prop: &F,
318) -> Vec<ConditionalBranchEnum>
319where
320 F: Fn(&str) -> Option<String>,
321{
322 let mut matching_branches = Vec::new();
323 let Some(branches) = region_doc.get("allOf").and_then(|value| value.as_array()) else {
324 return matching_branches;
325 };
326 for branch in branches {
327 let (Some(required), Some(if_properties)) = (
328 branch.get("if").and_then(|condition| condition.get("required")).and_then(|value| value.as_array()),
329 branch.get("if").and_then(|condition| condition.get("properties")).and_then(|value| value.as_object()),
330 ) else {
331 continue;
332 };
333
334 let mut property_constraints = Vec::new();
335 let mut all_required_match = true;
336 for property in required.iter().filter_map(|value| value.as_str()).filter(|property| *property != target_prop) {
337 let Some(expected) =
338 if_properties.get(property).and_then(|schema| schema.get("const")).and_then(|value| value.as_str())
339 else {
340 all_required_match = false;
341 break;
342 };
343 let Some(actual) = resolve_prop(property) else {
344 all_required_match = false;
345 break;
346 };
347 let matches = if normalize_engine_case && property == "Engine" {
348 actual.eq_ignore_ascii_case(expected)
349 } else {
350 actual == expected
351 };
352 if !matches {
353 all_required_match = false;
354 break;
355 }
356 property_constraints.push((property.to_string(), expected.to_string()));
357 }
358 if !all_required_match {
359 continue;
360 }
361 property_constraints.sort_unstable();
362
363 let Some(enum_values) = branch
364 .get("then")
365 .and_then(|then_schema| then_schema.get("properties"))
366 .and_then(|properties| properties.get(target_prop))
367 .and_then(|property_schema| property_schema.get("enum"))
368 .and_then(|value| value.as_array())
369 else {
370 continue;
371 };
372 matching_branches.push(ConditionalBranchEnum {
373 allowed_values: enum_values.iter().filter_map(|value| value.as_str().map(String::from)).collect(),
374 property_constraints,
375 });
376 }
377 matching_branches
378}
379
380fn invalid_branch_mismatch(branch_enums: &[ConditionalBranchEnum], value: &str) -> Option<ConditionalEnumMismatch> {
384 let failing_largest = branch_enums
385 .iter()
386 .filter(|branch| !branch.allowed_values.contains(value))
387 .max_by_key(|branch| branch.allowed_values.len())?;
388 let mut allowed_values: Vec<String> = failing_largest.allowed_values.iter().cloned().collect();
389 allowed_values.sort();
390 Some(ConditionalEnumMismatch { allowed_values, property_constraints: failing_largest.property_constraints.clone() })
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use serde_json::json;
397
398 fn flat_doc() -> serde_json::Map<String, serde_json::Value> {
399 json!({
402 "us-east-1": { "enum": ["common.type", "east.type"] },
403 "ap-east-2": { "enum": ["common.type", "special.type"] },
404 "all": { "enum": ["should.be.ignored"] },
405 "description": "generated"
406 })
407 .as_object()
408 .unwrap()
409 .clone()
410 }
411
412 #[test]
413 fn region_label_uses_region_or_any_region() {
414 assert_eq!(region_label(Some("us-west-2")), "us-west-2");
415 assert_eq!(region_label(None), "any region");
416 }
417
418 #[test]
419 fn flat_with_region_validates_that_region_only() {
420 let doc = flat_doc();
421 let allowed = flat_allowed_values(&doc, Some("us-east-1")).unwrap();
422 assert!(allowed.contains("east.type"));
423 assert!(!allowed.contains("special.type"), "special.type is not valid in us-east-1");
424 }
425
426 #[test]
427 fn flat_without_region_unions_all_regions() {
428 let doc = flat_doc();
429 let allowed = flat_allowed_values(&doc, None).unwrap();
430 assert!(allowed.contains("east.type"));
431 assert!(allowed.contains("special.type"), "special.type is valid in ap-east-2, so present in the union");
432 assert!(!allowed.contains("should.be.ignored"), "the synthetic 'all' key must not contribute");
433 }
434
435 #[test]
436 fn flat_unknown_configured_region_returns_none() {
437 let doc = flat_doc();
438 assert!(flat_allowed_values(&doc, Some("mars-north-1")).is_none());
439 }
440
441 #[test]
442 fn flat_without_region_returns_none_when_no_region_keys() {
443 let doc = json!({ "description": "x", "all": { "enum": ["a"] } }).as_object().unwrap().clone();
444 assert!(flat_allowed_values(&doc, None).is_none(), "no real region keys → nothing to validate");
445 }
446
447 fn conditional_doc() -> serde_json::Map<String, serde_json::Value> {
448 let branch = |class: &str| {
450 json!({
451 "if": { "required": ["Engine", "DBInstanceClass"], "properties": { "Engine": { "const": "mysql" } } },
452 "then": { "properties": { "DBInstanceClass": { "enum": [class] } } }
453 })
454 };
455 json!({
456 "us-east-1": { "allOf": [branch("db.small")] },
457 "ap-east-2": { "allOf": [ { "if": { "required": ["Engine", "DBInstanceClass"], "properties": { "Engine": { "const": "mysql" } } }, "then": { "properties": { "DBInstanceClass": { "enum": ["db.small", "db.big"] } } } } ] }
458 })
459 .as_object()
460 .unwrap()
461 .clone()
462 }
463
464 #[test]
465 fn conditional_with_region_flags_value_absent_from_that_region() {
466 let doc = conditional_doc();
467 let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
468 let invalid = conditional_invalid_enum(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.big", engine);
470 assert_eq!(invalid, Some(vec!["db.small".to_string()]));
471 }
472
473 #[test]
474 fn conditional_without_region_accepts_value_valid_in_any_region() {
475 let doc = conditional_doc();
476 let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
477 let invalid = conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.big", engine);
479 assert!(invalid.is_none(), "db.big is valid in ap-east-2 → not flagged when region is unset");
480 }
481
482 #[test]
483 fn conditional_without_region_flags_value_valid_nowhere() {
484 let doc = conditional_doc();
485 let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
486 let invalid = conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.bogus", engine);
487 assert!(invalid.is_some(), "db.bogus is valid in no region → flagged");
488 let rendered = invalid.unwrap();
489 assert!(rendered.contains(&"db.big".to_string()), "renders the largest failing branch across regions");
490 }
491
492 #[test]
493 fn conditional_message_names_the_engine_that_selected_the_enum() {
494 let doc = conditional_doc();
495 let engine = |property: &str| (property == "Engine").then(|| "MySQL".to_string());
496 let mismatch = conditional_enum_mismatch(&doc, None, "DBInstanceClass", true, "db.bogus", engine).unwrap();
497
498 assert_eq!(
499 conditional_mismatch_message("db.bogus", &mismatch, None),
500 "'db.bogus' is not one of ['db.big', 'db.small'] when Engine is 'mysql' in any region"
501 );
502 }
503
504 #[test]
505 fn conditional_message_includes_every_constraint_on_the_failing_branch() {
506 let doc = json!({
507 "us-east-1": {
508 "allOf": [{
509 "if": {
510 "required": ["Engine", "LicenseModel", "DBInstanceClass"],
511 "properties": {
512 "Engine": { "const": "oracle-se2" },
513 "LicenseModel": { "const": "license-included" }
514 }
515 },
516 "then": { "properties": { "DBInstanceClass": { "enum": ["db.allowed"] } } }
517 }]
518 }
519 })
520 .as_object()
521 .unwrap()
522 .clone();
523 let properties = |property: &str| match property {
524 "Engine" => Some("oracle-se2".to_string()),
525 "LicenseModel" => Some("license-included".to_string()),
526 _ => None,
527 };
528 let mismatch =
529 conditional_enum_mismatch(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.bogus", properties)
530 .unwrap();
531
532 assert_eq!(
533 conditional_mismatch_message("db.bogus", &mismatch, Some("us-east-1")),
534 "'db.bogus' is not one of ['db.allowed'] when Engine is 'oracle-se2' and LicenseModel is 'license-included' in 'us-east-1'"
535 );
536 }
537
538 #[test]
539 fn conditional_unmatched_engine_is_not_validated() {
540 let doc = conditional_doc();
541 let engine = |p: &str| (p == "Engine").then(|| "oracle".to_string());
544 assert!(conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.bogus", engine).is_none());
545 assert!(
546 conditional_invalid_enum(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.bogus", engine).is_none()
547 );
548 }
549}