Skip to main content

data_source/
rule_data.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashSet;
4
5// ──────────────────────────────────────────────────────────────────────────────
6// Extension-derived RuleData (preserved)
7// ──────────────────────────────────────────────────────────────────────────────
8
9const 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// ──────────────────────────────────────────────────────────────────────────────
96// RuleTables: typed representation of the rule tables data document
97// ──────────────────────────────────────────────────────────────────────────────
98
99/// A parsed segment from a `Resources/<Type>/...` property path.
100#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub enum PathSegment {
102    /// A literal property name (e.g. `Properties`, `BlockDeviceMappings`).
103    Literal(String),
104    /// A wildcard segment (`*`) representing any array index or map key.
105    Wildcard,
106}
107
108/// A parsed `Resources/<ResourceType>/Properties/...` path from the rule tables.
109///
110/// These paths follow the format:
111/// `Resources/<resource_type>/Properties/<prop1>/<prop2>/...`
112/// where `*` segments represent iteration over array items or map entries.
113#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
114pub struct ResourcePropertyPath {
115    /// The CloudFormation resource type (e.g. `AWS::EC2::Instance`).
116    pub resource_type: String,
117    /// The property path segments after `Resources/<type>/` (includes `Properties`).
118    pub segments: Vec<PathSegment>,
119}
120
121impl ResourcePropertyPath {
122    /// Parses a path string of the form `Resources/<type>/Properties/<seg1>/...`.
123    ///
124    /// Validation rules:
125    /// - Must start with `Resources/`
126    /// - Resource type must be a nonempty AWS type (e.g. `AWS::S3::Bucket`)
127    /// - First segment after the type must be `Properties`
128    /// - No literal segment may be empty
129    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/// The outer wrapper for the rule tables JSON document as produced by the sync pipeline.
164///
165/// The source JSON has a single top-level key `cfnlint_rule_tables` wrapping all fields.
166/// `deny_unknown_fields` ensures we detect schema drift at deserialization time.
167/// Used at build time for validation of the generated file.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct RuleTablesDocument {
171    /// The inner rule tables data.
172    pub cfnlint_rule_tables: RuleTablesRaw,
173}
174
175/// Normalized document wrapper with `rule_tables` as the top-level key.
176///
177/// The build pipeline normalizes the source-specific outer key to `rule_tables` before
178/// embedding, so runtime consumers (engines) deserialize with this struct.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181#[allow(dead_code)] // Used by library consumers; unused in build.rs compilation context.
182pub struct NormalizedRuleTablesDocument {
183    /// The inner rule tables data, keyed as `rule_tables`.
184    pub rule_tables: RuleTablesRaw,
185}
186
187/// Raw deserialized rule tables with all 22 fields exactly as stored in JSON.
188///
189/// Every field uses `deny_unknown_fields` on the outer struct to reject unknown keys.
190/// Path fields are stored as raw strings here; [`RuleTables`] holds the parsed forms.
191#[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/// Validated and parsed rule tables, ready for runtime consumption.
219///
220/// String arrays are checked for non-emptiness and uniqueness. Property path
221/// arrays are parsed into [`ResourcePropertyPath`] values. The regex pattern
222/// is stored as-is (compiled by the consumer if needed).
223#[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    /// Validates and parses a [`RuleTablesRaw`] into a fully validated [`RuleTables`].
251    ///
252    /// Validation rules:
253    /// - Every `Vec<String>` field must be non-empty, contain no empty strings, and have no duplicates.
254    /// - Every `*_property_paths` / `*_paths` field must parse into valid
255    ///   [`ResourcePropertyPath`] values.
256    /// - `ebs_iops_property_paths` entries must have exactly 1 or 2 wildcards and end in
257    ///   the `Wildcard, Literal("Ebs")` shape required by consumers.
258    /// - The `previous_generation_instance_pattern` must be non-empty.
259    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
364/// Validates that every ebs_iops_property_paths entry has exactly 1 or 2 wildcards and
365/// ends in the `[..., Wildcard, Literal("Ebs")]` shape required by consumers.
366fn 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    // ──────────────────────────────────────────────────────────────────────────
416    // Extension-derived RuleData tests (preserved)
417    // ──────────────────────────────────────────────────────────────────────────
418
419    #[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    // ──────────────────────────────────────────────────────────────────────────
472    // ResourcePropertyPath tests
473    // ──────────────────────────────────────────────────────────────────────────
474
475    #[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    // ──────────────────────────────────────────────────────────────────────────
548    // validate_nonempty_unique tests
549    // ──────────────────────────────────────────────────────────────────────────
550
551    #[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    // ──────────────────────────────────────────────────────────────────────────
579    // ebs_iops shape validation tests
580    // ──────────────────────────────────────────────────────────────────────────
581
582    #[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    // ──────────────────────────────────────────────────────────────────────────
660    // RuleTables deserialization and validation tests
661    // ──────────────────────────────────────────────────────────────────────────
662
663    #[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        // Verify wildcard parsing worked on known paths
675        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    /// Helper to build a minimal valid `RuleTablesRaw` then apply a mutation.
738    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}