1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashSet;
4
5const LAMBDA_FUNCTION_EXTENSION_KEY: &str = "Aws::Lambda::Function";
10const ECS_TASK_DEFINITION_EXTENSION_KEY: &str = "Aws::Ecs::Taskdefinition";
11const CLASSIC_LOAD_BALANCER_EXTENSION_KEY: &str = "Aws::Elasticloadbalancing::Loadbalancer";
12const LOAD_BALANCER_V2_LISTENER_EXTENSION_KEY: &str = "Aws::Elasticloadbalancingv2::Listener";
13
14#[derive(Debug, Serialize, Deserialize)]
15pub struct RuleData {
16 pub classic_load_balancer_certificate_protocols: Vec<String>,
17 pub fargate_supported_log_drivers: Vec<String>,
18 pub lambda_image_excluded_properties: Vec<String>,
19 pub lambda_reserved_environment_keys: Vec<String>,
20 pub load_balancer_v2_certificate_protocols: Vec<String>,
21}
22
23pub fn derive_from_extensions(extensions: &Value) -> Result<RuleData, String> {
24 Ok(RuleData {
25 classic_load_balancer_certificate_protocols: extract_unique_string_array(
26 extensions,
27 CLASSIC_LOAD_BALANCER_EXTENSION_KEY,
28 "/if/properties/Protocol/enum",
29 "classic load balancer certificate protocols",
30 )?,
31 fargate_supported_log_drivers: extract_unique_string_array(
32 extensions,
33 ECS_TASK_DEFINITION_EXTENSION_KEY,
34 "/then/allOf/1/properties/ContainerDefinitions/items/properties/LogConfiguration/then/properties/LogDriver/enum",
35 "Fargate supported log drivers",
36 )?,
37 lambda_image_excluded_properties: extract_unique_string_array(
38 extensions,
39 LAMBDA_FUNCTION_EXTENSION_KEY,
40 "/then/dependentExcluded/PackageType",
41 "Lambda image excluded properties",
42 )?,
43 lambda_reserved_environment_keys: extract_unique_string_array(
44 extensions,
45 LAMBDA_FUNCTION_EXTENSION_KEY,
46 "/propertyNames/not/enum",
47 "Lambda reserved environment keys",
48 )?,
49 load_balancer_v2_certificate_protocols: extract_unique_string_array(
50 extensions,
51 LOAD_BALANCER_V2_LISTENER_EXTENSION_KEY,
52 "/if/properties/Protocol/enum",
53 "load balancer v2 certificate protocols",
54 )?,
55 })
56}
57
58fn extract_unique_string_array(
59 extensions: &Value,
60 resource_type: &str,
61 pointer: &str,
62 table_name: &str,
63) -> Result<Vec<String>, String> {
64 let resource_extensions = extensions
65 .get(resource_type)
66 .and_then(Value::as_array)
67 .ok_or_else(|| format!("missing {resource_type} extension array"))?;
68
69 let matching_arrays: Vec<&Vec<Value>> = resource_extensions
70 .iter()
71 .filter_map(|extension| extension.pointer(pointer).and_then(Value::as_array))
72 .collect();
73
74 let [string_values] = matching_arrays.as_slice() else {
75 return Err(format!("expected exactly one {table_name} extension, found {}", matching_arrays.len()));
76 };
77
78 let mut strings = Vec::with_capacity(string_values.len());
79 let mut unique_strings = HashSet::with_capacity(string_values.len());
80 for value in *string_values {
81 let string = value.as_str().ok_or_else(|| format!("{table_name} entry must be a string"))?;
82 if !unique_strings.insert(string) {
83 return Err(format!("duplicate {table_name} entry: {string}"));
84 }
85 strings.push(string.to_string());
86 }
87
88 if strings.is_empty() {
89 return Err(format!("{table_name} extension is empty"));
90 }
91
92 Ok(strings)
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub enum PathSegment {
102 Literal(String),
104 Wildcard,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
114pub struct ResourcePropertyPath {
115 pub resource_type: String,
117 pub segments: Vec<PathSegment>,
119}
120
121impl ResourcePropertyPath {
122 pub fn parse(raw: &str) -> Result<Self, String> {
130 let parts: Vec<&str> = raw.split('/').collect();
131 if parts.is_empty() || parts[0] != "Resources" {
132 return Err(format!("path must start with 'Resources/': {raw}"));
133 }
134 if parts.len() < 4 {
135 return Err(format!("path too short, expected at least Resources/<type>/Properties/<segment>: {raw}"));
136 }
137 let resource_type = parts[1].to_string();
138 if resource_type.is_empty() {
139 return Err(format!("empty resource type in path: {raw}"));
140 }
141 if !resource_type.starts_with("AWS::") {
142 return Err(format!("resource type must start with 'AWS::': {raw}"));
143 }
144 if parts[2] != "Properties" {
145 return Err(format!("first segment after resource type must be 'Properties': {raw}"));
146 }
147 let segments: Vec<PathSegment> = parts[2..]
148 .iter()
149 .map(|&s| {
150 if s == "*" {
151 Ok(PathSegment::Wildcard)
152 } else if s.is_empty() {
153 Err(format!("empty literal segment in path: {raw}"))
154 } else {
155 Ok(PathSegment::Literal(s.to_string()))
156 }
157 })
158 .collect::<Result<Vec<_>, _>>()?;
159 Ok(ResourcePropertyPath { resource_type, segments })
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct RuleTablesDocument {
171 pub cfnlint_rule_tables: RuleTablesRaw,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181#[allow(dead_code)] pub struct NormalizedRuleTablesDocument {
183 pub rule_tables: RuleTablesRaw,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct RuleTablesRaw {
194 pub api_gateway_mixing_resource_types: Vec<String>,
195 pub ebs_iops_ignored_volume_types: Vec<String>,
196 pub ebs_iops_property_paths: Vec<String>,
197 pub iam_role_arn_property_paths: Vec<String>,
198 pub image_id_parameter_types: Vec<String>,
199 pub image_id_property_paths: Vec<String>,
200 pub lambda_zip_required_properties: Vec<String>,
201 pub package_property_paths: Vec<String>,
202 pub password_property_names: Vec<String>,
203 pub previous_generation_instance_pattern: String,
204 pub previous_generation_instance_property_paths: Vec<String>,
205 pub resource_policy_paths: Vec<String>,
206 pub secret_dynamic_reference_property_paths: Vec<String>,
207 pub snapshot_capable_resource_types: Vec<String>,
208 pub snapstart_recommendation_excluded_runtimes: Vec<String>,
209 pub snapstart_recommendation_runtime_prefixes: Vec<String>,
210 pub snapstart_runtime_prefixes: Vec<String>,
211 pub snapstart_supported_regions: Vec<String>,
212 pub snapstart_unsupported_runtime_prefixes: Vec<String>,
213 pub snapstart_unsupported_runtimes: Vec<String>,
214 pub update_policy_resource_types: Vec<String>,
215 pub valid_parameter_types: Vec<String>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct RuleTables {
225 pub api_gateway_mixing_resource_types: Vec<String>,
226 pub ebs_iops_ignored_volume_types: Vec<String>,
227 pub ebs_iops_property_paths: Vec<ResourcePropertyPath>,
228 pub iam_role_arn_property_paths: Vec<ResourcePropertyPath>,
229 pub image_id_parameter_types: Vec<String>,
230 pub image_id_property_paths: Vec<ResourcePropertyPath>,
231 pub lambda_zip_required_properties: Vec<String>,
232 pub package_property_paths: Vec<ResourcePropertyPath>,
233 pub password_property_names: Vec<String>,
234 pub previous_generation_instance_pattern: String,
235 pub previous_generation_instance_property_paths: Vec<ResourcePropertyPath>,
236 pub resource_policy_paths: Vec<ResourcePropertyPath>,
237 pub secret_dynamic_reference_property_paths: Vec<ResourcePropertyPath>,
238 pub snapshot_capable_resource_types: Vec<String>,
239 pub snapstart_recommendation_excluded_runtimes: Vec<String>,
240 pub snapstart_recommendation_runtime_prefixes: Vec<String>,
241 pub snapstart_runtime_prefixes: Vec<String>,
242 pub snapstart_supported_regions: Vec<String>,
243 pub snapstart_unsupported_runtime_prefixes: Vec<String>,
244 pub snapstart_unsupported_runtimes: Vec<String>,
245 pub update_policy_resource_types: Vec<String>,
246 pub valid_parameter_types: Vec<String>,
247}
248
249impl RuleTables {
250 pub fn validate(raw: RuleTablesRaw) -> Result<Self, String> {
260 validate_nonempty_unique(&raw.api_gateway_mixing_resource_types, "api_gateway_mixing_resource_types")?;
261 validate_nonempty_unique(&raw.ebs_iops_ignored_volume_types, "ebs_iops_ignored_volume_types")?;
262 validate_nonempty_unique(&raw.ebs_iops_property_paths, "ebs_iops_property_paths")?;
263 validate_nonempty_unique(&raw.iam_role_arn_property_paths, "iam_role_arn_property_paths")?;
264 validate_nonempty_unique(&raw.image_id_parameter_types, "image_id_parameter_types")?;
265 validate_nonempty_unique(&raw.image_id_property_paths, "image_id_property_paths")?;
266 validate_nonempty_unique(&raw.lambda_zip_required_properties, "lambda_zip_required_properties")?;
267 validate_nonempty_unique(&raw.package_property_paths, "package_property_paths")?;
268 validate_nonempty_unique(&raw.password_property_names, "password_property_names")?;
269 validate_nonempty_unique(
270 &raw.previous_generation_instance_property_paths,
271 "previous_generation_instance_property_paths",
272 )?;
273 validate_nonempty_unique(&raw.resource_policy_paths, "resource_policy_paths")?;
274 validate_nonempty_unique(
275 &raw.secret_dynamic_reference_property_paths,
276 "secret_dynamic_reference_property_paths",
277 )?;
278 validate_nonempty_unique(&raw.snapshot_capable_resource_types, "snapshot_capable_resource_types")?;
279 validate_nonempty_unique(
280 &raw.snapstart_recommendation_excluded_runtimes,
281 "snapstart_recommendation_excluded_runtimes",
282 )?;
283 validate_nonempty_unique(
284 &raw.snapstart_recommendation_runtime_prefixes,
285 "snapstart_recommendation_runtime_prefixes",
286 )?;
287 validate_nonempty_unique(&raw.snapstart_runtime_prefixes, "snapstart_runtime_prefixes")?;
288 validate_nonempty_unique(&raw.snapstart_supported_regions, "snapstart_supported_regions")?;
289 validate_nonempty_unique(
290 &raw.snapstart_unsupported_runtime_prefixes,
291 "snapstart_unsupported_runtime_prefixes",
292 )?;
293 validate_nonempty_unique(&raw.snapstart_unsupported_runtimes, "snapstart_unsupported_runtimes")?;
294 validate_nonempty_unique(&raw.update_policy_resource_types, "update_policy_resource_types")?;
295 validate_nonempty_unique(&raw.valid_parameter_types, "valid_parameter_types")?;
296
297 if raw.previous_generation_instance_pattern.is_empty() {
298 return Err("previous_generation_instance_pattern must not be empty".to_string());
299 }
300
301 let ebs_iops_property_paths = parse_paths(&raw.ebs_iops_property_paths, "ebs_iops_property_paths")?;
302 validate_ebs_iops_shape(&ebs_iops_property_paths)?;
303 let iam_role_arn_property_paths = parse_paths(&raw.iam_role_arn_property_paths, "iam_role_arn_property_paths")?;
304 let image_id_property_paths = parse_paths(&raw.image_id_property_paths, "image_id_property_paths")?;
305 let package_property_paths = parse_paths(&raw.package_property_paths, "package_property_paths")?;
306 let previous_generation_instance_property_paths = parse_paths(
307 &raw.previous_generation_instance_property_paths,
308 "previous_generation_instance_property_paths",
309 )?;
310 let resource_policy_paths = parse_paths(&raw.resource_policy_paths, "resource_policy_paths")?;
311 let secret_dynamic_reference_property_paths =
312 parse_paths(&raw.secret_dynamic_reference_property_paths, "secret_dynamic_reference_property_paths")?;
313
314 Ok(RuleTables {
315 api_gateway_mixing_resource_types: raw.api_gateway_mixing_resource_types,
316 ebs_iops_ignored_volume_types: raw.ebs_iops_ignored_volume_types,
317 ebs_iops_property_paths,
318 iam_role_arn_property_paths,
319 image_id_parameter_types: raw.image_id_parameter_types,
320 image_id_property_paths,
321 lambda_zip_required_properties: raw.lambda_zip_required_properties,
322 package_property_paths,
323 password_property_names: raw.password_property_names,
324 previous_generation_instance_pattern: raw.previous_generation_instance_pattern,
325 previous_generation_instance_property_paths,
326 resource_policy_paths,
327 secret_dynamic_reference_property_paths,
328 snapshot_capable_resource_types: raw.snapshot_capable_resource_types,
329 snapstart_recommendation_excluded_runtimes: raw.snapstart_recommendation_excluded_runtimes,
330 snapstart_recommendation_runtime_prefixes: raw.snapstart_recommendation_runtime_prefixes,
331 snapstart_runtime_prefixes: raw.snapstart_runtime_prefixes,
332 snapstart_supported_regions: raw.snapstart_supported_regions,
333 snapstart_unsupported_runtime_prefixes: raw.snapstart_unsupported_runtime_prefixes,
334 snapstart_unsupported_runtimes: raw.snapstart_unsupported_runtimes,
335 update_policy_resource_types: raw.update_policy_resource_types,
336 valid_parameter_types: raw.valid_parameter_types,
337 })
338 }
339}
340
341fn validate_nonempty_unique(values: &[String], field_name: &str) -> Result<(), String> {
342 if values.is_empty() {
343 return Err(format!("{field_name} must not be empty"));
344 }
345 let mut seen = HashSet::with_capacity(values.len());
346 for v in values {
347 if v.is_empty() {
348 return Err(format!("empty string entry in {field_name}"));
349 }
350 if !seen.insert(v.as_str()) {
351 return Err(format!("duplicate entry in {field_name}: {v}"));
352 }
353 }
354 Ok(())
355}
356
357fn parse_paths(raw_paths: &[String], field_name: &str) -> Result<Vec<ResourcePropertyPath>, String> {
358 raw_paths
359 .iter()
360 .map(|p| ResourcePropertyPath::parse(p).map_err(|e| format!("invalid path in {field_name}: {e}")))
361 .collect()
362}
363
364fn validate_ebs_iops_shape(paths: &[ResourcePropertyPath]) -> Result<(), String> {
367 for path in paths {
368 let wc_count = path.segments.iter().filter(|s| matches!(s, PathSegment::Wildcard)).count();
369 if wc_count == 0 || wc_count > 2 {
370 return Err(format!(
371 "ebs_iops_property_paths entry must have exactly 1 or 2 wildcards, \
372 found {wc_count} in: Resources/{}/{}",
373 path.resource_type,
374 path.segments
375 .iter()
376 .map(|s| match s {
377 PathSegment::Literal(l) => l.as_str(),
378 PathSegment::Wildcard => "*",
379 })
380 .collect::<Vec<_>>()
381 .join("/")
382 ));
383 }
384 let len = path.segments.len();
385 if len < 2 {
386 return Err(format!(
387 "ebs_iops_property_paths entry too short to end in */Ebs: Resources/{}/...",
388 path.resource_type
389 ));
390 }
391 let penultimate = &path.segments[len - 2];
392 let last = &path.segments[len - 1];
393 if !matches!(penultimate, PathSegment::Wildcard) || !matches!(last, PathSegment::Literal(s) if s == "Ebs") {
394 return Err(format!(
395 "ebs_iops_property_paths entry must end in */Ebs: Resources/{}/{}",
396 path.resource_type,
397 path.segments
398 .iter()
399 .map(|s| match s {
400 PathSegment::Literal(l) => l.as_str(),
401 PathSegment::Wildcard => "*",
402 })
403 .collect::<Vec<_>>()
404 .join("/")
405 ));
406 }
407 }
408 Ok(())
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
420 fn derives_rule_data_from_synced_extensions() {
421 let extensions: Value = serde_json::from_slice(include_bytes!("../generated/schema-validator/extensions.json"))
422 .expect("synced extensions should parse");
423
424 let rule_data = derive_from_extensions(&extensions).expect("rule data should derive");
425
426 assert_eq!(rule_data.classic_load_balancer_certificate_protocols, ["HTTPS", "SSL"]);
427 assert_eq!(rule_data.fargate_supported_log_drivers, ["awslogs", "splunk", "awsfirelens"]);
428 assert_eq!(rule_data.lambda_image_excluded_properties, ["Handler", "Runtime", "Layers"]);
429 assert_eq!(rule_data.lambda_reserved_environment_keys.len(), 18);
430 assert!(rule_data.lambda_reserved_environment_keys.contains(&"AWS_ACCESS_KEY".to_string()));
431 assert!(rule_data.lambda_reserved_environment_keys.contains(&"AWS_LAMBDA_INITIALIZATION_TYPE".to_string()));
432 assert_eq!(rule_data.load_balancer_v2_certificate_protocols, ["HTTPS", "TLS"]);
433 }
434
435 #[test]
436 fn rejects_missing_extension_table() {
437 let error = extract_unique_string_array(
438 &serde_json::json!({ LAMBDA_FUNCTION_EXTENSION_KEY: [] }),
439 LAMBDA_FUNCTION_EXTENSION_KEY,
440 "/propertyNames/not/enum",
441 "Lambda reserved environment keys",
442 )
443 .expect_err("missing extension table must fail");
444
445 assert!(error.contains("expected exactly one"));
446 }
447
448 #[test]
449 fn rejects_duplicate_extension_values() {
450 let extensions = serde_json::json!({
451 LAMBDA_FUNCTION_EXTENSION_KEY: [{
452 "propertyNames": {
453 "not": {
454 "enum": ["AWS_REGION", "AWS_REGION"]
455 }
456 }
457 }]
458 });
459
460 let error = extract_unique_string_array(
461 &extensions,
462 LAMBDA_FUNCTION_EXTENSION_KEY,
463 "/propertyNames/not/enum",
464 "Lambda reserved environment keys",
465 )
466 .expect_err("duplicate extension values must fail");
467
468 assert_eq!(error, "duplicate Lambda reserved environment keys entry: AWS_REGION");
469 }
470
471 #[test]
476 fn parses_simple_property_path() {
477 let path = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/Properties/BucketName").unwrap();
478 assert_eq!(path.resource_type, "AWS::S3::Bucket");
479 assert_eq!(
480 path.segments,
481 vec![PathSegment::Literal("Properties".into()), PathSegment::Literal("BucketName".into())]
482 );
483 }
484
485 #[test]
486 fn parses_path_with_wildcard() {
487 let path =
488 ResourcePropertyPath::parse("Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Ebs").unwrap();
489 assert_eq!(path.resource_type, "AWS::EC2::Instance");
490 assert_eq!(
491 path.segments,
492 vec![
493 PathSegment::Literal("Properties".into()),
494 PathSegment::Literal("BlockDeviceMappings".into()),
495 PathSegment::Wildcard,
496 PathSegment::Literal("Ebs".into()),
497 ]
498 );
499 }
500
501 #[test]
502 fn parses_path_with_multiple_wildcards() {
503 let path = ResourcePropertyPath::parse(
504 "Resources/AWS::EC2::SpotFleet/Properties/SpotFleetRequestConfigData/LaunchSpecifications/*/BlockDeviceMappings/*/Ebs",
505 )
506 .unwrap();
507 assert_eq!(path.resource_type, "AWS::EC2::SpotFleet");
508 assert_eq!(path.segments.iter().filter(|s| **s == PathSegment::Wildcard).count(), 2);
509 }
510
511 #[test]
512 fn rejects_path_missing_resources_prefix() {
513 let err = ResourcePropertyPath::parse("AWS::S3::Bucket/Properties/Name").unwrap_err();
514 assert!(err.contains("must start with 'Resources/'"));
515 }
516
517 #[test]
518 fn rejects_path_too_short() {
519 let err = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/Properties").unwrap_err();
520 assert!(err.contains("too short"));
521 }
522
523 #[test]
524 fn rejects_empty_resource_type() {
525 let err = ResourcePropertyPath::parse("Resources//Properties/Name").unwrap_err();
526 assert!(err.contains("empty resource type"));
527 }
528
529 #[test]
530 fn rejects_non_aws_resource_type() {
531 let err = ResourcePropertyPath::parse("Resources/Custom::MyThing/Properties/Foo").unwrap_err();
532 assert!(err.contains("must start with 'AWS::'"));
533 }
534
535 #[test]
536 fn rejects_missing_properties_segment() {
537 let err = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/BucketName/Foo").unwrap_err();
538 assert!(err.contains("first segment after resource type must be 'Properties'"));
539 }
540
541 #[test]
542 fn rejects_empty_literal_segment() {
543 let err = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/Properties//Name").unwrap_err();
544 assert!(err.contains("empty literal segment"));
545 }
546
547 #[test]
552 fn rejects_empty_string_array() {
553 let err = validate_nonempty_unique(&[], "test_field").unwrap_err();
554 assert!(err.contains("must not be empty"));
555 }
556
557 #[test]
558 fn rejects_duplicate_entries() {
559 let values = vec!["a".to_string(), "b".to_string(), "a".to_string()];
560 let err = validate_nonempty_unique(&values, "test_field").unwrap_err();
561 assert!(err.contains("duplicate entry"));
562 assert!(err.contains("test_field"));
563 }
564
565 #[test]
566 fn validates_unique_entries_pass() {
567 let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
568 validate_nonempty_unique(&values, "test_field").unwrap();
569 }
570
571 #[test]
572 fn rejects_empty_string_entry() {
573 let values = vec!["valid".to_string(), "".to_string()];
574 let err = validate_nonempty_unique(&values, "test_field").unwrap_err();
575 assert!(err.contains("empty string entry in test_field"));
576 }
577
578 #[test]
583 fn ebs_iops_accepts_single_wildcard_ebs_suffix() {
584 let paths = vec![
585 ResourcePropertyPath::parse("Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Ebs").unwrap(),
586 ];
587 validate_ebs_iops_shape(&paths).unwrap();
588 }
589
590 #[test]
591 fn ebs_iops_accepts_double_wildcard_ebs_suffix() {
592 let paths = vec![ResourcePropertyPath::parse(
593 "Resources/AWS::EC2::SpotFleet/Properties/SpotFleetRequestConfigData/LaunchSpecifications/*/BlockDeviceMappings/*/Ebs",
594 )
595 .unwrap()];
596 validate_ebs_iops_shape(&paths).unwrap();
597 }
598
599 #[test]
600 fn ebs_iops_rejects_zero_wildcards() {
601 let path = ResourcePropertyPath {
602 resource_type: "AWS::EC2::Instance".into(),
603 segments: vec![
604 PathSegment::Literal("Properties".into()),
605 PathSegment::Literal("BlockDeviceMappings".into()),
606 PathSegment::Literal("Ebs".into()),
607 ],
608 };
609 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
610 assert!(err.contains("exactly 1 or 2 wildcards"));
611 }
612
613 #[test]
614 fn ebs_iops_rejects_three_wildcards() {
615 let path = ResourcePropertyPath {
616 resource_type: "AWS::EC2::Instance".into(),
617 segments: vec![
618 PathSegment::Literal("Properties".into()),
619 PathSegment::Wildcard,
620 PathSegment::Wildcard,
621 PathSegment::Wildcard,
622 PathSegment::Literal("Ebs".into()),
623 ],
624 };
625 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
626 assert!(err.contains("exactly 1 or 2 wildcards"));
627 }
628
629 #[test]
630 fn ebs_iops_rejects_missing_ebs_suffix() {
631 let path = ResourcePropertyPath {
632 resource_type: "AWS::EC2::Instance".into(),
633 segments: vec![
634 PathSegment::Literal("Properties".into()),
635 PathSegment::Literal("BlockDeviceMappings".into()),
636 PathSegment::Wildcard,
637 PathSegment::Literal("Iops".into()),
638 ],
639 };
640 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
641 assert!(err.contains("must end in */Ebs"));
642 }
643
644 #[test]
645 fn ebs_iops_rejects_non_wildcard_before_ebs() {
646 let path = ResourcePropertyPath {
647 resource_type: "AWS::EC2::Instance".into(),
648 segments: vec![
649 PathSegment::Literal("Properties".into()),
650 PathSegment::Wildcard,
651 PathSegment::Literal("NotWildcard".into()),
652 PathSegment::Literal("Ebs".into()),
653 ],
654 };
655 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
656 assert!(err.contains("must end in */Ebs"));
657 }
658
659 #[test]
664 fn deserializes_and_validates_synced_rule_tables() {
665 let bytes = include_bytes!("../generated/data/cfnlint_rule_tables.json");
666 let doc: RuleTablesDocument = serde_json::from_slice(bytes).expect("rule tables JSON must deserialize");
667 let tables = RuleTables::validate(doc.cfnlint_rule_tables).expect("rule tables must validate");
668
669 assert!(!tables.api_gateway_mixing_resource_types.is_empty());
670 assert!(!tables.ebs_iops_property_paths.is_empty());
671 assert!(!tables.valid_parameter_types.is_empty());
672 assert!(!tables.previous_generation_instance_pattern.is_empty());
673
674 let has_wildcard = tables.ebs_iops_property_paths.iter().any(|p| p.segments.contains(&PathSegment::Wildcard));
676 assert!(has_wildcard, "ebs_iops_property_paths should contain wildcard segments");
677 }
678
679 #[test]
680 fn rejects_unknown_fields_in_rule_tables() {
681 let json = r#"{"cfnlint_rule_tables": {"unknown_field": [], "api_gateway_mixing_resource_types": []}}"#;
682 let result: Result<RuleTablesDocument, _> = serde_json::from_str(json);
683 assert!(result.is_err(), "unknown fields must be rejected");
684 }
685
686 #[test]
687 fn rule_tables_rejects_empty_pattern() {
688 let raw = make_minimal_raw(|r| r.previous_generation_instance_pattern = String::new());
689 let err = RuleTables::validate(raw).unwrap_err();
690 assert!(err.contains("previous_generation_instance_pattern must not be empty"));
691 }
692
693 #[test]
694 fn rule_tables_rejects_empty_paths_field() {
695 let raw = make_minimal_raw(|r| r.ebs_iops_property_paths = vec![]);
696 let err = RuleTables::validate(raw).unwrap_err();
697 assert!(err.contains("ebs_iops_property_paths must not be empty"));
698 }
699
700 #[test]
701 fn rule_tables_rejects_invalid_path_format() {
702 let raw = make_minimal_raw(|r| {
703 r.ebs_iops_property_paths = vec!["NotResources/Foo/Bar".to_string()];
704 });
705 let err = RuleTables::validate(raw).unwrap_err();
706 assert!(err.contains("invalid path in ebs_iops_property_paths"));
707 }
708
709 #[test]
710 fn rule_tables_rejects_ebs_iops_without_wildcard_ebs_suffix() {
711 let raw = make_minimal_raw(|r| {
712 r.ebs_iops_property_paths =
713 vec!["Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Iops".into()];
714 });
715 let err = RuleTables::validate(raw).unwrap_err();
716 assert!(err.contains("must end in */Ebs"));
717 }
718
719 #[test]
720 fn rule_tables_rejects_ebs_iops_with_zero_wildcards() {
721 let raw = make_minimal_raw(|r| {
722 r.ebs_iops_property_paths = vec!["Resources/AWS::EC2::Instance/Properties/Ebs".into()];
723 });
724 let err = RuleTables::validate(raw).unwrap_err();
725 assert!(err.contains("exactly 1 or 2 wildcards"));
726 }
727
728 #[test]
729 fn rule_tables_rejects_empty_string_entry_in_array() {
730 let raw = make_minimal_raw(|r| {
731 r.password_property_names = vec!["Password".into(), "".into()];
732 });
733 let err = RuleTables::validate(raw).unwrap_err();
734 assert!(err.contains("empty string entry in password_property_names"));
735 }
736
737 fn make_minimal_raw(mutate: impl FnOnce(&mut RuleTablesRaw)) -> RuleTablesRaw {
739 let mut raw = RuleTablesRaw {
740 api_gateway_mixing_resource_types: vec!["AWS::ApiGateway::Method".into()],
741 ebs_iops_ignored_volume_types: vec!["gp2".into()],
742 ebs_iops_property_paths: vec!["Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Ebs".into()],
743 iam_role_arn_property_paths: vec![
744 "Resources/AWS::Backup::BackupSelection/Properties/BackupSelection/IamRoleArn".into(),
745 ],
746 image_id_parameter_types: vec!["AWS::EC2::Image::Id".into()],
747 image_id_property_paths: vec!["Resources/AWS::AutoScaling::LaunchConfiguration/Properties/ImageId".into()],
748 lambda_zip_required_properties: vec!["Handler".into()],
749 package_property_paths: vec!["Resources/AWS::ApiGateway::RestApi/Properties/BodyS3Location".into()],
750 password_property_names: vec!["Password".into()],
751 previous_generation_instance_pattern: "(^|\\\\.)([cmr][1-3])($|\\\\.)".into(),
752 previous_generation_instance_property_paths: vec![
753 "Resources/AWS::EC2::Instance/Properties/InstanceType".into(),
754 ],
755 resource_policy_paths: vec!["Resources/AWS::KMS::Key/Properties/KeyPolicy".into()],
756 secret_dynamic_reference_property_paths: vec![
757 "Resources/AWS::IAM::User/Properties/LoginProfile/Password".into(),
758 ],
759 snapshot_capable_resource_types: vec!["AWS::RDS::DBInstance".into()],
760 snapstart_recommendation_excluded_runtimes: vec!["java8".into()],
761 snapstart_recommendation_runtime_prefixes: vec!["java".into()],
762 snapstart_runtime_prefixes: vec!["python".into()],
763 snapstart_supported_regions: vec!["us-east-1".into()],
764 snapstart_unsupported_runtime_prefixes: vec!["dotnetcore".into()],
765 snapstart_unsupported_runtimes: vec!["dotnet6".into()],
766 update_policy_resource_types: vec!["AWS::AutoScaling::AutoScalingGroup".into()],
767 valid_parameter_types: vec!["String".into()],
768 };
769 mutate(&mut raw);
770 raw
771 }
772}