cloudformation-validate-schema-validator 1.9.0-beta

AWS CloudFormation Validate
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
pub mod catalog;
pub(crate) mod compiled;
pub(crate) mod convert;
pub mod overlay;
pub mod store;
pub mod validate;

#[cfg(feature = "uniffi-bindings")]
uniffi::setup_scaffolding!();

pub use catalog::OverlayCatalog;
pub use data_source::{AdditionalSchemaSource, SchemaSourceError};
pub use overlay::{MAX_OVERLAY_DEPTH, SchemaOverlayError};
pub use store::{CompiledSchemaStore, OverlayOutcome};

/// Eagerly decompress all embedded data LazyLocks. Intended to be called once at
/// process/module start in environments that benefit from front-loading cost
pub fn prewarm_embedded_data() {
    data_source::embedded::warm_all();
}

use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
use log::{info, warn};
use rules::{RuleInfo, lookup_rule};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use template_model::SemanticModel;

pub struct SchemaValidationResult {
    pub diagnostics: Vec<Diagnostic>,
    pub metric: PhaseMetric,
}

/// Configuration for constructing a [`SchemaValidator`] with optional overlay
/// schemas. Bindings and the CLI use this to build the validator separately from
/// the rule engine.
#[derive(Default, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
#[serde(rename_all = "camelCase")]
pub struct SchemaValidatorConfig {
    /// Additional CloudFormation resource provider schemas to merge on top of the
    /// bundled schemas before schema validation.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
    pub additional_schemas: Vec<AdditionalSchemaSource>,
}

impl SchemaValidatorConfig {
    /// Starts from the default configuration (no overlays).
    pub fn new() -> Self {
        Self::default()
    }

    /// Adds resource provider schemas to overlay on the bundled ones.
    pub fn with_additional_schemas(mut self, schemas: impl IntoIterator<Item = AdditionalSchemaSource>) -> Self {
        self.additional_schemas.extend(schemas);
        self
    }
}

/// Error reported when schema-validator construction from config fails.
#[derive(Debug)]
pub enum SchemaValidatorConfigError {
    /// An overlay schema source failed to resolve (bad JSON, missing type name, etc.).
    Source(SchemaSourceError),
    /// An overlay schema failed to apply (cycle, depth limit, etc.).
    Overlay(SchemaOverlayError),
}

impl std::fmt::Display for SchemaValidatorConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SchemaValidatorConfigError::Source(e) => write!(f, "{e}"),
            SchemaValidatorConfigError::Overlay(e) => {
                write!(f, "Failed to apply an additional schema: {e}")
            }
        }
    }
}

impl std::error::Error for SchemaValidatorConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            SchemaValidatorConfigError::Source(e) => Some(e),
            SchemaValidatorConfigError::Overlay(e) => Some(e),
        }
    }
}

impl From<SchemaSourceError> for SchemaValidatorConfigError {
    fn from(e: SchemaSourceError) -> Self {
        SchemaValidatorConfigError::Source(e)
    }
}

impl From<SchemaOverlayError> for SchemaValidatorConfigError {
    fn from(e: SchemaOverlayError) -> Self {
        SchemaValidatorConfigError::Overlay(e)
    }
}

/// Builds an [`OverlayCatalog`] from resolved schema overlay pairs. This is the
/// internal helper that engines use for standalone construction when they need to
/// derive a catalog from additional schemas without constructing a full
/// [`SchemaValidator`].
///
/// Returns `Ok(catalog)` where `catalog.is_empty()` when `overlays` is empty.
#[doc(hidden)]
pub fn build_overlay_catalog<I, S>(overlays: I) -> Result<OverlayCatalog, SchemaOverlayError>
where
    I: IntoIterator<Item = (S, serde_json::Value)>,
    S: AsRef<str>,
{
    let mut store = CompiledSchemaStore::new();
    let mut type_names: Vec<String> = Vec::new();
    for (type_name, schema) in overlays {
        let type_name = type_name.as_ref();
        store.apply_overlay(type_name, &schema)?;
        if !type_names.contains(&type_name.to_string()) {
            type_names.push(type_name.to_string());
        }
    }
    build_validated_overlay_catalog(&store, &type_names)
}

/// Builds metadata only after validating references against the final merged
/// store. Per-overlay application permits forward references because a later
/// overlay may supply the target; anything still dangling here cannot resolve.
fn build_validated_overlay_catalog(
    store: &CompiledSchemaStore,
    type_names: &[String],
) -> Result<OverlayCatalog, SchemaOverlayError> {
    for type_name in type_names {
        if let Some(schema) = store.get(type_name)
            && let Some((path, target)) = overlay::find_dangling_refs(schema).into_iter().next()
        {
            return Err(SchemaOverlayError::DanglingRef { type_name: type_name.clone(), path, target });
        }
    }
    Ok(OverlayCatalog::from_store(store, type_names))
}

pub struct SchemaValidator {
    store: CompiledSchemaStore,
    catalog: OverlayCatalog,
    init_metric: PhaseMetric,
}

impl Default for SchemaValidator {
    fn default() -> Self {
        Self::new_default()
    }
}

impl SchemaValidator {
    /// Constructs a [`SchemaValidator`] from a [`SchemaValidatorConfig`],
    /// resolving and applying each additional schema once. The resulting
    /// [`OverlayCatalog`] is stored and accessible via [`Self::overlay_catalog`].
    ///
    /// This is the canonical construction path: bindings, the CLI, and library
    /// embedders all go through this method. A configured overlay behaves
    /// identically no matter which front end supplied it.
    ///
    /// For call sites that need no overlay schemas, [`SchemaValidator::default()`]
    /// provides an infallible, zero-config constructor.
    ///
    /// Returns an error when an overlay is malformed.
    pub fn new(config: SchemaValidatorConfig) -> Result<Self, SchemaValidatorConfigError> {
        if config.additional_schemas.is_empty() {
            return Ok(Self::new_default());
        }
        let overlays: Vec<(String, serde_json::Value)> =
            config.additional_schemas.iter().map(|s| s.resolve()).collect::<Result<Vec<_>, _>>()?;
        Ok(Self::try_with_additional_schemas(overlays)?)
    }

    /// Infallible constructor with no overlay schemas - used by `Default`.
    fn new_default() -> Self {
        let start = web_time::Instant::now();
        let store = CompiledSchemaStore::new();
        Self::finish(store, OverlayCatalog::default(), 0, start)
    }

    /// Construct a validator whose schema store has `additional_schemas` merged
    /// on top of the bundled schemas.
    ///
    /// Each item is a `(type_name, schema)` pair where `schema` is an
    /// already-parsed CloudFormation resource provider schema (registry JSON, the
    /// same shape the build-time schema compiler consumes) and `type_name` is the
    /// resource type it applies to. Overlays are applied in order, so a later
    /// overlay sees the result of the earlier ones. The
    /// [`crate::overlay`] module documents the merge model and its scope
    /// limits.
    ///
    /// Fails, without building a validator, when an overlay is not a JSON object,
    /// carries no type name, nests too deeply, defines a cyclic `$ref` graph, or
    /// would change nothing. An overlay for a type the bundled schemas do not
    /// contain is registered as a new type and logged, since that is also what a
    /// misspelled type name looks like.
    pub fn try_with_additional_schemas<I, S>(additional_schemas: I) -> Result<Self, SchemaOverlayError>
    where
        I: IntoIterator<Item = (S, serde_json::Value)>,
        S: AsRef<str>,
    {
        let start = web_time::Instant::now();
        let mut store = CompiledSchemaStore::new();
        let mut applied = 0usize;
        let mut type_names: Vec<String> = Vec::new();
        for (type_name, schema) in additional_schemas {
            let type_name = type_name.as_ref();
            if store.apply_overlay(type_name, &schema)? == OverlayOutcome::Inserted {
                if should_warn_for_inserted_type(type_name) {
                    warn!(
                        "Additional schema for '{type_name}' matches no bundled resource type; registering it as a \
                         new type. Check the type name if this was meant to extend an existing type."
                    );
                } else {
                    info!("Additional schema for '{type_name}' registered as a new type (private/custom provider).");
                }
            }
            if !type_names.contains(&type_name.to_string()) {
                type_names.push(type_name.to_string());
            }
            applied += 1;
        }
        let catalog = build_validated_overlay_catalog(&store, &type_names)?;
        Ok(Self::finish(store, catalog, applied, start))
    }

    /// The overlay catalog derived from the final merged schema store. Empty
    /// when no additional schemas were applied. Rule engines use this to access
    /// overlay-aware metadata without rebuilding the store.
    #[doc(hidden)]
    pub fn overlay_catalog(&self) -> &OverlayCatalog {
        &self.catalog
    }

    fn finish(
        store: CompiledSchemaStore,
        catalog: OverlayCatalog,
        overlays_applied: usize,
        start: web_time::Instant,
    ) -> Self {
        let init_metric = phase_metric(start);
        if overlays_applied > 0 {
            info!(
                "SchemaValidator initialized: {} schemas loaded, {overlays_applied} overlay schema(s) applied",
                store.len()
            );
        } else {
            info!("SchemaValidator initialized: {} schemas loaded", store.len());
        }
        SchemaValidator { store, catalog, init_metric }
    }

    pub fn init_metric(&self) -> &PhaseMetric {
        &self.init_metric
    }

    /// Validates every resource. `region` is the configured AWS region, or `None`
    /// when the caller supplied none - in which case region-scoped checks widen to
    /// the union of all regions (a resource type or enum value is flagged only when
    /// it is unavailable in every region) rather than assuming a default region.
    pub fn validate(&self, model: &Arc<SemanticModel>, region: Option<&str>) -> SchemaValidationResult {
        let start = web_time::Instant::now();
        let diagnostics = validate::validate_all_resources(&self.store, model, region);
        let metric = phase_metric(start);
        SchemaValidationResult { diagnostics, metric }
    }

    pub fn enrich_context(&self, diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
        validate::enrich_schema_context(diagnostics, &self.store, model);
    }

    pub fn schema_count(&self) -> usize {
        self.store.len()
    }

    pub fn list_rules(&self) -> Vec<RuleInfo> {
        // Every rule ID the schema-validator can emit (see src/validate.rs).
        const SCHEMA_RULE_IDS: &[&str] = &[
            "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "W3030", "F3031", "F3032",
            "F3033", "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "F3006", "E9006", "E2531", "E2533", "E3710",
            "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002",
        ];
        SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect()
    }
}

/// Whether inserting a previously unknown type should be logged at `warn` level.
///
/// Unknown AWS::* types are likely a misspelled type name - warn so the caller
/// notices. Private/custom provider types (e.g. `MyOrg::Network::Firewall`) are
/// intentionally new and insertion is the expected outcome - info is sufficient.
pub(crate) fn should_warn_for_inserted_type(type_name: &str) -> bool {
    type_name.starts_with("AWS::")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_without_overlays_matches_default_construction() {
        let config = SchemaValidatorConfig::default();
        let validator = SchemaValidator::new(config).expect("an empty config builds");
        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
    }

    #[test]
    fn new_applies_configured_overlays() {
        let config = SchemaValidatorConfig {
            additional_schemas: vec![AdditionalSchemaSource {
                type_name: None,
                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
                    .into(),
            }],
        };
        let validator = SchemaValidator::new(config).expect("a valid overlay builds");
        let template = br#"
Resources:
  Fn:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: "exports.handler = async () => {};"
      Role: arn:aws:iam::123456789012:role/lambda-role
      Runtime: nodejs18.x
      Handler: index.handler
      TestForOverride: enabled
"#;
        let model = Arc::new(SemanticModel::from_bytes(template).expect("template parses"));
        let diagnostics = validator.validate(&model, Some("us-east-1")).diagnostics;
        assert!(
            !diagnostics.iter().any(|d| d.rule_id == "F3002"),
            "the configured overlay must reach schema validation, got: {:?}",
            diagnostics.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
        );
    }

    #[test]
    fn new_reports_a_malformed_overlay() {
        let config = SchemaValidatorConfig {
            additional_schemas: vec![AdditionalSchemaSource {
                type_name: Some("AWS::Test::Cycle".into()),
                schema: r##"{"properties":{"P":{"$ref":"#/definitions/D"}},"definitions":{"D":{"$ref":"#/definitions/D"}}}"##
                    .into(),
            }],
        };
        let message = match SchemaValidator::new(config) {
            Err(e) => e.to_string(),
            Ok(_) => panic!("a cyclic overlay must fail construction"),
        };
        assert!(message.contains("cycle"), "the error must describe the cycle, got: {message}");
    }

    #[test]
    fn config_builds_validator_with_overlays() {
        let config = SchemaValidatorConfig {
            additional_schemas: vec![AdditionalSchemaSource {
                type_name: None,
                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
                    .into(),
            }],
        };
        let validator = SchemaValidator::new(config).expect("valid config builds");
        assert!(
            !validator.overlay_catalog().is_empty(),
            "validator built from SchemaValidatorConfig must expose a populated catalog"
        );
        assert!(
            validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()),
            "catalog must include the overlaid type"
        );
    }

    #[test]
    fn config_empty_builds_default_validator() {
        let config = SchemaValidatorConfig::default();
        let validator = SchemaValidator::new(config).expect("empty config builds");
        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
        assert!(validator.overlay_catalog().is_empty());
    }

    #[test]
    fn should_warn_for_aws_types_but_not_private_providers() {
        assert!(
            should_warn_for_inserted_type("AWS::S3::NewBucket"),
            "unknown AWS::* types should warn - likely typo or unpublished"
        );
        assert!(
            should_warn_for_inserted_type("AWS::EC2::Instance"),
            "AWS namespace insertion warns regardless of whether the type exists"
        );
        assert!(
            !should_warn_for_inserted_type("MyOrg::Network::Firewall"),
            "private/custom provider types are supported and should not warn"
        );
        assert!(
            !should_warn_for_inserted_type("Acme::Storage::ObjectStore"),
            "third-party provider types are supported and should not warn"
        );
    }
}