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 image_id_parameter_types: Vec<String>,
198 pub image_id_property_paths: Vec<String>,
199 pub lambda_zip_required_properties: Vec<String>,
200 pub package_property_paths: Vec<String>,
201 pub password_property_names: Vec<String>,
202 pub previous_generation_instance_pattern: String,
203 pub previous_generation_instance_property_paths: Vec<String>,
204 pub resource_policy_paths: Vec<String>,
205 pub secret_dynamic_reference_property_paths: Vec<String>,
206 pub snapshot_capable_resource_types: Vec<String>,
207 pub snapstart_recommendation_excluded_runtimes: Vec<String>,
208 pub snapstart_recommendation_runtime_prefixes: Vec<String>,
209 pub snapstart_runtime_prefixes: Vec<String>,
210 pub snapstart_supported_regions: Vec<String>,
211 pub snapstart_unsupported_runtime_prefixes: Vec<String>,
212 pub snapstart_unsupported_runtimes: Vec<String>,
213 pub update_policy_resource_types: Vec<String>,
214 pub valid_parameter_types: Vec<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RuleTables {
224 pub api_gateway_mixing_resource_types: Vec<String>,
225 pub ebs_iops_ignored_volume_types: Vec<String>,
226 pub ebs_iops_property_paths: Vec<ResourcePropertyPath>,
227 pub image_id_parameter_types: Vec<String>,
228 pub image_id_property_paths: Vec<ResourcePropertyPath>,
229 pub lambda_zip_required_properties: Vec<String>,
230 pub package_property_paths: Vec<ResourcePropertyPath>,
231 pub password_property_names: Vec<String>,
232 pub previous_generation_instance_pattern: String,
233 pub previous_generation_instance_property_paths: Vec<ResourcePropertyPath>,
234 pub resource_policy_paths: Vec<ResourcePropertyPath>,
235 pub secret_dynamic_reference_property_paths: Vec<ResourcePropertyPath>,
236 pub snapshot_capable_resource_types: Vec<String>,
237 pub snapstart_recommendation_excluded_runtimes: Vec<String>,
238 pub snapstart_recommendation_runtime_prefixes: Vec<String>,
239 pub snapstart_runtime_prefixes: Vec<String>,
240 pub snapstart_supported_regions: Vec<String>,
241 pub snapstart_unsupported_runtime_prefixes: Vec<String>,
242 pub snapstart_unsupported_runtimes: Vec<String>,
243 pub update_policy_resource_types: Vec<String>,
244 pub valid_parameter_types: Vec<String>,
245}
246
247impl RuleTables {
248 pub fn validate(raw: RuleTablesRaw) -> Result<Self, String> {
258 validate_nonempty_unique(&raw.api_gateway_mixing_resource_types, "api_gateway_mixing_resource_types")?;
259 validate_nonempty_unique(&raw.ebs_iops_ignored_volume_types, "ebs_iops_ignored_volume_types")?;
260 validate_nonempty_unique(&raw.ebs_iops_property_paths, "ebs_iops_property_paths")?;
261 validate_nonempty_unique(&raw.image_id_parameter_types, "image_id_parameter_types")?;
262 validate_nonempty_unique(&raw.image_id_property_paths, "image_id_property_paths")?;
263 validate_nonempty_unique(&raw.lambda_zip_required_properties, "lambda_zip_required_properties")?;
264 validate_nonempty_unique(&raw.package_property_paths, "package_property_paths")?;
265 validate_nonempty_unique(&raw.password_property_names, "password_property_names")?;
266 validate_nonempty_unique(
267 &raw.previous_generation_instance_property_paths,
268 "previous_generation_instance_property_paths",
269 )?;
270 validate_nonempty_unique(&raw.resource_policy_paths, "resource_policy_paths")?;
271 validate_nonempty_unique(
272 &raw.secret_dynamic_reference_property_paths,
273 "secret_dynamic_reference_property_paths",
274 )?;
275 validate_nonempty_unique(&raw.snapshot_capable_resource_types, "snapshot_capable_resource_types")?;
276 validate_nonempty_unique(
277 &raw.snapstart_recommendation_excluded_runtimes,
278 "snapstart_recommendation_excluded_runtimes",
279 )?;
280 validate_nonempty_unique(
281 &raw.snapstart_recommendation_runtime_prefixes,
282 "snapstart_recommendation_runtime_prefixes",
283 )?;
284 validate_nonempty_unique(&raw.snapstart_runtime_prefixes, "snapstart_runtime_prefixes")?;
285 validate_nonempty_unique(&raw.snapstart_supported_regions, "snapstart_supported_regions")?;
286 validate_nonempty_unique(
287 &raw.snapstart_unsupported_runtime_prefixes,
288 "snapstart_unsupported_runtime_prefixes",
289 )?;
290 validate_nonempty_unique(&raw.snapstart_unsupported_runtimes, "snapstart_unsupported_runtimes")?;
291 validate_nonempty_unique(&raw.update_policy_resource_types, "update_policy_resource_types")?;
292 validate_nonempty_unique(&raw.valid_parameter_types, "valid_parameter_types")?;
293
294 if raw.previous_generation_instance_pattern.is_empty() {
295 return Err("previous_generation_instance_pattern must not be empty".to_string());
296 }
297
298 let ebs_iops_property_paths = parse_paths(&raw.ebs_iops_property_paths, "ebs_iops_property_paths")?;
299 validate_ebs_iops_shape(&ebs_iops_property_paths)?;
300 let image_id_property_paths = parse_paths(&raw.image_id_property_paths, "image_id_property_paths")?;
301 let package_property_paths = parse_paths(&raw.package_property_paths, "package_property_paths")?;
302 let previous_generation_instance_property_paths = parse_paths(
303 &raw.previous_generation_instance_property_paths,
304 "previous_generation_instance_property_paths",
305 )?;
306 let resource_policy_paths = parse_paths(&raw.resource_policy_paths, "resource_policy_paths")?;
307 let secret_dynamic_reference_property_paths =
308 parse_paths(&raw.secret_dynamic_reference_property_paths, "secret_dynamic_reference_property_paths")?;
309
310 Ok(RuleTables {
311 api_gateway_mixing_resource_types: raw.api_gateway_mixing_resource_types,
312 ebs_iops_ignored_volume_types: raw.ebs_iops_ignored_volume_types,
313 ebs_iops_property_paths,
314 image_id_parameter_types: raw.image_id_parameter_types,
315 image_id_property_paths,
316 lambda_zip_required_properties: raw.lambda_zip_required_properties,
317 package_property_paths,
318 password_property_names: raw.password_property_names,
319 previous_generation_instance_pattern: raw.previous_generation_instance_pattern,
320 previous_generation_instance_property_paths,
321 resource_policy_paths,
322 secret_dynamic_reference_property_paths,
323 snapshot_capable_resource_types: raw.snapshot_capable_resource_types,
324 snapstart_recommendation_excluded_runtimes: raw.snapstart_recommendation_excluded_runtimes,
325 snapstart_recommendation_runtime_prefixes: raw.snapstart_recommendation_runtime_prefixes,
326 snapstart_runtime_prefixes: raw.snapstart_runtime_prefixes,
327 snapstart_supported_regions: raw.snapstart_supported_regions,
328 snapstart_unsupported_runtime_prefixes: raw.snapstart_unsupported_runtime_prefixes,
329 snapstart_unsupported_runtimes: raw.snapstart_unsupported_runtimes,
330 update_policy_resource_types: raw.update_policy_resource_types,
331 valid_parameter_types: raw.valid_parameter_types,
332 })
333 }
334}
335
336fn validate_nonempty_unique(values: &[String], field_name: &str) -> Result<(), String> {
337 if values.is_empty() {
338 return Err(format!("{field_name} must not be empty"));
339 }
340 let mut seen = HashSet::with_capacity(values.len());
341 for v in values {
342 if v.is_empty() {
343 return Err(format!("empty string entry in {field_name}"));
344 }
345 if !seen.insert(v.as_str()) {
346 return Err(format!("duplicate entry in {field_name}: {v}"));
347 }
348 }
349 Ok(())
350}
351
352fn parse_paths(raw_paths: &[String], field_name: &str) -> Result<Vec<ResourcePropertyPath>, String> {
353 raw_paths
354 .iter()
355 .map(|p| ResourcePropertyPath::parse(p).map_err(|e| format!("invalid path in {field_name}: {e}")))
356 .collect()
357}
358
359fn validate_ebs_iops_shape(paths: &[ResourcePropertyPath]) -> Result<(), String> {
362 for path in paths {
363 let wc_count = path.segments.iter().filter(|s| matches!(s, PathSegment::Wildcard)).count();
364 if wc_count == 0 || wc_count > 2 {
365 return Err(format!(
366 "ebs_iops_property_paths entry must have exactly 1 or 2 wildcards, \
367 found {wc_count} in: Resources/{}/{}",
368 path.resource_type,
369 path.segments
370 .iter()
371 .map(|s| match s {
372 PathSegment::Literal(l) => l.as_str(),
373 PathSegment::Wildcard => "*",
374 })
375 .collect::<Vec<_>>()
376 .join("/")
377 ));
378 }
379 let len = path.segments.len();
380 if len < 2 {
381 return Err(format!(
382 "ebs_iops_property_paths entry too short to end in */Ebs: Resources/{}/...",
383 path.resource_type
384 ));
385 }
386 let penultimate = &path.segments[len - 2];
387 let last = &path.segments[len - 1];
388 if !matches!(penultimate, PathSegment::Wildcard) || !matches!(last, PathSegment::Literal(s) if s == "Ebs") {
389 return Err(format!(
390 "ebs_iops_property_paths entry must end in */Ebs: Resources/{}/{}",
391 path.resource_type,
392 path.segments
393 .iter()
394 .map(|s| match s {
395 PathSegment::Literal(l) => l.as_str(),
396 PathSegment::Wildcard => "*",
397 })
398 .collect::<Vec<_>>()
399 .join("/")
400 ));
401 }
402 }
403 Ok(())
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
415 fn derives_rule_data_from_synced_extensions() {
416 let extensions: Value = serde_json::from_slice(include_bytes!("../generated/schema-validator/extensions.json"))
417 .expect("synced extensions should parse");
418
419 let rule_data = derive_from_extensions(&extensions).expect("rule data should derive");
420
421 assert_eq!(rule_data.classic_load_balancer_certificate_protocols, ["HTTPS", "SSL"]);
422 assert_eq!(rule_data.fargate_supported_log_drivers, ["awslogs", "splunk", "awsfirelens"]);
423 assert_eq!(rule_data.lambda_image_excluded_properties, ["Handler", "Runtime", "Layers"]);
424 assert_eq!(rule_data.lambda_reserved_environment_keys.len(), 18);
425 assert!(rule_data.lambda_reserved_environment_keys.contains(&"AWS_ACCESS_KEY".to_string()));
426 assert!(rule_data.lambda_reserved_environment_keys.contains(&"AWS_LAMBDA_INITIALIZATION_TYPE".to_string()));
427 assert_eq!(rule_data.load_balancer_v2_certificate_protocols, ["HTTPS", "TLS"]);
428 }
429
430 #[test]
431 fn rejects_missing_extension_table() {
432 let error = extract_unique_string_array(
433 &serde_json::json!({ LAMBDA_FUNCTION_EXTENSION_KEY: [] }),
434 LAMBDA_FUNCTION_EXTENSION_KEY,
435 "/propertyNames/not/enum",
436 "Lambda reserved environment keys",
437 )
438 .expect_err("missing extension table must fail");
439
440 assert!(error.contains("expected exactly one"));
441 }
442
443 #[test]
444 fn rejects_duplicate_extension_values() {
445 let extensions = serde_json::json!({
446 LAMBDA_FUNCTION_EXTENSION_KEY: [{
447 "propertyNames": {
448 "not": {
449 "enum": ["AWS_REGION", "AWS_REGION"]
450 }
451 }
452 }]
453 });
454
455 let error = extract_unique_string_array(
456 &extensions,
457 LAMBDA_FUNCTION_EXTENSION_KEY,
458 "/propertyNames/not/enum",
459 "Lambda reserved environment keys",
460 )
461 .expect_err("duplicate extension values must fail");
462
463 assert_eq!(error, "duplicate Lambda reserved environment keys entry: AWS_REGION");
464 }
465
466 #[test]
471 fn parses_simple_property_path() {
472 let path = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/Properties/BucketName").unwrap();
473 assert_eq!(path.resource_type, "AWS::S3::Bucket");
474 assert_eq!(
475 path.segments,
476 vec![PathSegment::Literal("Properties".into()), PathSegment::Literal("BucketName".into())]
477 );
478 }
479
480 #[test]
481 fn parses_path_with_wildcard() {
482 let path =
483 ResourcePropertyPath::parse("Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Ebs").unwrap();
484 assert_eq!(path.resource_type, "AWS::EC2::Instance");
485 assert_eq!(
486 path.segments,
487 vec![
488 PathSegment::Literal("Properties".into()),
489 PathSegment::Literal("BlockDeviceMappings".into()),
490 PathSegment::Wildcard,
491 PathSegment::Literal("Ebs".into()),
492 ]
493 );
494 }
495
496 #[test]
497 fn parses_path_with_multiple_wildcards() {
498 let path = ResourcePropertyPath::parse(
499 "Resources/AWS::EC2::SpotFleet/Properties/SpotFleetRequestConfigData/LaunchSpecifications/*/BlockDeviceMappings/*/Ebs",
500 )
501 .unwrap();
502 assert_eq!(path.resource_type, "AWS::EC2::SpotFleet");
503 assert_eq!(path.segments.iter().filter(|s| **s == PathSegment::Wildcard).count(), 2);
504 }
505
506 #[test]
507 fn rejects_path_missing_resources_prefix() {
508 let err = ResourcePropertyPath::parse("AWS::S3::Bucket/Properties/Name").unwrap_err();
509 assert!(err.contains("must start with 'Resources/'"));
510 }
511
512 #[test]
513 fn rejects_path_too_short() {
514 let err = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/Properties").unwrap_err();
515 assert!(err.contains("too short"));
516 }
517
518 #[test]
519 fn rejects_empty_resource_type() {
520 let err = ResourcePropertyPath::parse("Resources//Properties/Name").unwrap_err();
521 assert!(err.contains("empty resource type"));
522 }
523
524 #[test]
525 fn rejects_non_aws_resource_type() {
526 let err = ResourcePropertyPath::parse("Resources/Custom::MyThing/Properties/Foo").unwrap_err();
527 assert!(err.contains("must start with 'AWS::'"));
528 }
529
530 #[test]
531 fn rejects_missing_properties_segment() {
532 let err = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/BucketName/Foo").unwrap_err();
533 assert!(err.contains("first segment after resource type must be 'Properties'"));
534 }
535
536 #[test]
537 fn rejects_empty_literal_segment() {
538 let err = ResourcePropertyPath::parse("Resources/AWS::S3::Bucket/Properties//Name").unwrap_err();
539 assert!(err.contains("empty literal segment"));
540 }
541
542 #[test]
547 fn rejects_empty_string_array() {
548 let err = validate_nonempty_unique(&[], "test_field").unwrap_err();
549 assert!(err.contains("must not be empty"));
550 }
551
552 #[test]
553 fn rejects_duplicate_entries() {
554 let values = vec!["a".to_string(), "b".to_string(), "a".to_string()];
555 let err = validate_nonempty_unique(&values, "test_field").unwrap_err();
556 assert!(err.contains("duplicate entry"));
557 assert!(err.contains("test_field"));
558 }
559
560 #[test]
561 fn validates_unique_entries_pass() {
562 let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
563 validate_nonempty_unique(&values, "test_field").unwrap();
564 }
565
566 #[test]
567 fn rejects_empty_string_entry() {
568 let values = vec!["valid".to_string(), "".to_string()];
569 let err = validate_nonempty_unique(&values, "test_field").unwrap_err();
570 assert!(err.contains("empty string entry in test_field"));
571 }
572
573 #[test]
578 fn ebs_iops_accepts_single_wildcard_ebs_suffix() {
579 let paths = vec![
580 ResourcePropertyPath::parse("Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Ebs").unwrap(),
581 ];
582 validate_ebs_iops_shape(&paths).unwrap();
583 }
584
585 #[test]
586 fn ebs_iops_accepts_double_wildcard_ebs_suffix() {
587 let paths = vec![ResourcePropertyPath::parse(
588 "Resources/AWS::EC2::SpotFleet/Properties/SpotFleetRequestConfigData/LaunchSpecifications/*/BlockDeviceMappings/*/Ebs",
589 )
590 .unwrap()];
591 validate_ebs_iops_shape(&paths).unwrap();
592 }
593
594 #[test]
595 fn ebs_iops_rejects_zero_wildcards() {
596 let path = ResourcePropertyPath {
597 resource_type: "AWS::EC2::Instance".into(),
598 segments: vec![
599 PathSegment::Literal("Properties".into()),
600 PathSegment::Literal("BlockDeviceMappings".into()),
601 PathSegment::Literal("Ebs".into()),
602 ],
603 };
604 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
605 assert!(err.contains("exactly 1 or 2 wildcards"));
606 }
607
608 #[test]
609 fn ebs_iops_rejects_three_wildcards() {
610 let path = ResourcePropertyPath {
611 resource_type: "AWS::EC2::Instance".into(),
612 segments: vec![
613 PathSegment::Literal("Properties".into()),
614 PathSegment::Wildcard,
615 PathSegment::Wildcard,
616 PathSegment::Wildcard,
617 PathSegment::Literal("Ebs".into()),
618 ],
619 };
620 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
621 assert!(err.contains("exactly 1 or 2 wildcards"));
622 }
623
624 #[test]
625 fn ebs_iops_rejects_missing_ebs_suffix() {
626 let path = ResourcePropertyPath {
627 resource_type: "AWS::EC2::Instance".into(),
628 segments: vec![
629 PathSegment::Literal("Properties".into()),
630 PathSegment::Literal("BlockDeviceMappings".into()),
631 PathSegment::Wildcard,
632 PathSegment::Literal("Iops".into()),
633 ],
634 };
635 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
636 assert!(err.contains("must end in */Ebs"));
637 }
638
639 #[test]
640 fn ebs_iops_rejects_non_wildcard_before_ebs() {
641 let path = ResourcePropertyPath {
642 resource_type: "AWS::EC2::Instance".into(),
643 segments: vec![
644 PathSegment::Literal("Properties".into()),
645 PathSegment::Wildcard,
646 PathSegment::Literal("NotWildcard".into()),
647 PathSegment::Literal("Ebs".into()),
648 ],
649 };
650 let err = validate_ebs_iops_shape(&[path]).unwrap_err();
651 assert!(err.contains("must end in */Ebs"));
652 }
653
654 #[test]
659 fn deserializes_and_validates_synced_rule_tables() {
660 let bytes = include_bytes!("../generated/data/cfnlint_rule_tables.json");
661 let doc: RuleTablesDocument = serde_json::from_slice(bytes).expect("rule tables JSON must deserialize");
662 let tables = RuleTables::validate(doc.cfnlint_rule_tables).expect("rule tables must validate");
663
664 assert!(!tables.api_gateway_mixing_resource_types.is_empty());
665 assert!(!tables.ebs_iops_property_paths.is_empty());
666 assert!(!tables.valid_parameter_types.is_empty());
667 assert!(!tables.previous_generation_instance_pattern.is_empty());
668
669 let has_wildcard = tables.ebs_iops_property_paths.iter().any(|p| p.segments.contains(&PathSegment::Wildcard));
671 assert!(has_wildcard, "ebs_iops_property_paths should contain wildcard segments");
672 }
673
674 #[test]
675 fn rejects_unknown_fields_in_rule_tables() {
676 let json = r#"{"cfnlint_rule_tables": {"unknown_field": [], "api_gateway_mixing_resource_types": []}}"#;
677 let result: Result<RuleTablesDocument, _> = serde_json::from_str(json);
678 assert!(result.is_err(), "unknown fields must be rejected");
679 }
680
681 #[test]
682 fn rule_tables_rejects_empty_pattern() {
683 let raw = make_minimal_raw(|r| r.previous_generation_instance_pattern = String::new());
684 let err = RuleTables::validate(raw).unwrap_err();
685 assert!(err.contains("previous_generation_instance_pattern must not be empty"));
686 }
687
688 #[test]
689 fn rule_tables_rejects_empty_paths_field() {
690 let raw = make_minimal_raw(|r| r.ebs_iops_property_paths = vec![]);
691 let err = RuleTables::validate(raw).unwrap_err();
692 assert!(err.contains("ebs_iops_property_paths must not be empty"));
693 }
694
695 #[test]
696 fn rule_tables_rejects_invalid_path_format() {
697 let raw = make_minimal_raw(|r| {
698 r.ebs_iops_property_paths = vec!["NotResources/Foo/Bar".to_string()];
699 });
700 let err = RuleTables::validate(raw).unwrap_err();
701 assert!(err.contains("invalid path in ebs_iops_property_paths"));
702 }
703
704 #[test]
705 fn rule_tables_rejects_ebs_iops_without_wildcard_ebs_suffix() {
706 let raw = make_minimal_raw(|r| {
707 r.ebs_iops_property_paths =
708 vec!["Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Iops".into()];
709 });
710 let err = RuleTables::validate(raw).unwrap_err();
711 assert!(err.contains("must end in */Ebs"));
712 }
713
714 #[test]
715 fn rule_tables_rejects_ebs_iops_with_zero_wildcards() {
716 let raw = make_minimal_raw(|r| {
717 r.ebs_iops_property_paths = vec!["Resources/AWS::EC2::Instance/Properties/Ebs".into()];
718 });
719 let err = RuleTables::validate(raw).unwrap_err();
720 assert!(err.contains("exactly 1 or 2 wildcards"));
721 }
722
723 #[test]
724 fn rule_tables_rejects_empty_string_entry_in_array() {
725 let raw = make_minimal_raw(|r| {
726 r.password_property_names = vec!["Password".into(), "".into()];
727 });
728 let err = RuleTables::validate(raw).unwrap_err();
729 assert!(err.contains("empty string entry in password_property_names"));
730 }
731
732 fn make_minimal_raw(mutate: impl FnOnce(&mut RuleTablesRaw)) -> RuleTablesRaw {
734 let mut raw = RuleTablesRaw {
735 api_gateway_mixing_resource_types: vec!["AWS::ApiGateway::Method".into()],
736 ebs_iops_ignored_volume_types: vec!["gp2".into()],
737 ebs_iops_property_paths: vec!["Resources/AWS::EC2::Instance/Properties/BlockDeviceMappings/*/Ebs".into()],
738 image_id_parameter_types: vec!["AWS::EC2::Image::Id".into()],
739 image_id_property_paths: vec!["Resources/AWS::AutoScaling::LaunchConfiguration/Properties/ImageId".into()],
740 lambda_zip_required_properties: vec!["Handler".into()],
741 package_property_paths: vec!["Resources/AWS::ApiGateway::RestApi/Properties/BodyS3Location".into()],
742 password_property_names: vec!["Password".into()],
743 previous_generation_instance_pattern: "(^|\\\\.)([cmr][1-3])($|\\\\.)".into(),
744 previous_generation_instance_property_paths: vec![
745 "Resources/AWS::EC2::Instance/Properties/InstanceType".into(),
746 ],
747 resource_policy_paths: vec!["Resources/AWS::KMS::Key/Properties/KeyPolicy".into()],
748 secret_dynamic_reference_property_paths: vec![
749 "Resources/AWS::IAM::User/Properties/LoginProfile/Password".into(),
750 ],
751 snapshot_capable_resource_types: vec!["AWS::RDS::DBInstance".into()],
752 snapstart_recommendation_excluded_runtimes: vec!["java8".into()],
753 snapstart_recommendation_runtime_prefixes: vec!["java".into()],
754 snapstart_runtime_prefixes: vec!["python".into()],
755 snapstart_supported_regions: vec!["us-east-1".into()],
756 snapstart_unsupported_runtime_prefixes: vec!["dotnetcore".into()],
757 snapstart_unsupported_runtimes: vec!["dotnet6".into()],
758 update_policy_resource_types: vec!["AWS::AutoScaling::AutoScalingGroup".into()],
759 valid_parameter_types: vec!["String".into()],
760 };
761 mutate(&mut raw);
762 raw
763 }
764}