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 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 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/// Validated and parsed rule tables, ready for runtime consumption.
218///
219/// String arrays are checked for non-emptiness and uniqueness. Property path
220/// arrays are parsed into [`ResourcePropertyPath`] values. The regex pattern
221/// is stored as-is (compiled by the consumer if needed).
222#[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    /// Validates and parses a [`RuleTablesRaw`] into a fully validated [`RuleTables`].
249    ///
250    /// Validation rules:
251    /// - Every `Vec<String>` field must be non-empty, contain no empty strings, and have no duplicates.
252    /// - Every `*_property_paths` / `*_paths` field must parse into valid
253    ///   [`ResourcePropertyPath`] values.
254    /// - `ebs_iops_property_paths` entries must have exactly 1 or 2 wildcards and end in
255    ///   the `Wildcard, Literal("Ebs")` shape required by consumers.
256    /// - The `previous_generation_instance_pattern` must be non-empty.
257    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
359/// Validates that every ebs_iops_property_paths entry has exactly 1 or 2 wildcards and
360/// ends in the `[..., Wildcard, Literal("Ebs")]` shape required by consumers.
361fn 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    // ──────────────────────────────────────────────────────────────────────────
411    // Extension-derived RuleData tests (preserved)
412    // ──────────────────────────────────────────────────────────────────────────
413
414    #[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    // ──────────────────────────────────────────────────────────────────────────
467    // ResourcePropertyPath tests
468    // ──────────────────────────────────────────────────────────────────────────
469
470    #[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    // ──────────────────────────────────────────────────────────────────────────
543    // validate_nonempty_unique tests
544    // ──────────────────────────────────────────────────────────────────────────
545
546    #[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    // ──────────────────────────────────────────────────────────────────────────
574    // ebs_iops shape validation tests
575    // ──────────────────────────────────────────────────────────────────────────
576
577    #[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    // ──────────────────────────────────────────────────────────────────────────
655    // RuleTables deserialization and validation tests
656    // ──────────────────────────────────────────────────────────────────────────
657
658    #[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        // Verify wildcard parsing worked on known paths
670        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    /// Helper to build a minimal valid `RuleTablesRaw` then apply a mutation.
733    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}