Skip to main content

schema_validator/
lib.rs

1pub mod catalog;
2pub(crate) mod compiled;
3pub(crate) mod convert;
4pub mod overlay;
5pub mod store;
6pub mod validate;
7
8#[cfg(feature = "uniffi-bindings")]
9uniffi::setup_scaffolding!();
10
11pub use catalog::OverlayCatalog;
12pub use data_source::{AdditionalSchemaSource, SchemaSourceError};
13pub use overlay::{MAX_OVERLAY_DEPTH, SchemaOverlayError};
14pub use store::{CompiledSchemaStore, OverlayOutcome};
15
16/// Eagerly decompress all embedded data LazyLocks. Intended to be called once at
17/// process/module start in environments that benefit from front-loading cost
18pub fn prewarm_embedded_data() {
19    data_source::embedded::warm_all();
20}
21
22use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
23use log::{info, warn};
24use rules::{RuleInfo, lookup_rule};
25use serde::{Deserialize, Serialize};
26use std::sync::Arc;
27use template_model::SemanticModel;
28
29pub struct SchemaValidationResult {
30    pub diagnostics: Vec<Diagnostic>,
31    pub metric: PhaseMetric,
32}
33
34/// Configuration for constructing a [`SchemaValidator`] with optional overlay
35/// schemas. Bindings and the CLI use this to build the validator separately from
36/// the rule engine.
37#[derive(Default, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
39#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
40#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
41#[serde(rename_all = "camelCase")]
42pub struct SchemaValidatorConfig {
43    /// Additional CloudFormation resource provider schemas to merge on top of the
44    /// bundled schemas before schema validation.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
47    pub additional_schemas: Vec<AdditionalSchemaSource>,
48}
49
50impl SchemaValidatorConfig {
51    /// Starts from the default configuration (no overlays).
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Adds resource provider schemas to overlay on the bundled ones.
57    pub fn with_additional_schemas(mut self, schemas: impl IntoIterator<Item = AdditionalSchemaSource>) -> Self {
58        self.additional_schemas.extend(schemas);
59        self
60    }
61}
62
63/// Error reported when schema-validator construction from config fails.
64#[derive(Debug)]
65pub enum SchemaValidatorConfigError {
66    /// An overlay schema source failed to resolve (bad JSON, missing type name, etc.).
67    Source(SchemaSourceError),
68    /// An overlay schema failed to apply (cycle, depth limit, etc.).
69    Overlay(SchemaOverlayError),
70}
71
72impl std::fmt::Display for SchemaValidatorConfigError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            SchemaValidatorConfigError::Source(e) => write!(f, "{e}"),
76            SchemaValidatorConfigError::Overlay(e) => {
77                write!(f, "Failed to apply an additional schema: {e}")
78            }
79        }
80    }
81}
82
83impl std::error::Error for SchemaValidatorConfigError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        match self {
86            SchemaValidatorConfigError::Source(e) => Some(e),
87            SchemaValidatorConfigError::Overlay(e) => Some(e),
88        }
89    }
90}
91
92impl From<SchemaSourceError> for SchemaValidatorConfigError {
93    fn from(e: SchemaSourceError) -> Self {
94        SchemaValidatorConfigError::Source(e)
95    }
96}
97
98impl From<SchemaOverlayError> for SchemaValidatorConfigError {
99    fn from(e: SchemaOverlayError) -> Self {
100        SchemaValidatorConfigError::Overlay(e)
101    }
102}
103
104/// Builds an [`OverlayCatalog`] from resolved schema overlay pairs. This is the
105/// internal helper that engines use for standalone construction when they need to
106/// derive a catalog from additional schemas without constructing a full
107/// [`SchemaValidator`].
108///
109/// Returns `Ok(catalog)` where `catalog.is_empty()` when `overlays` is empty.
110#[doc(hidden)]
111pub fn build_overlay_catalog<I, S>(overlays: I) -> Result<OverlayCatalog, SchemaOverlayError>
112where
113    I: IntoIterator<Item = (S, serde_json::Value)>,
114    S: AsRef<str>,
115{
116    let mut store = CompiledSchemaStore::new();
117    let mut type_names: Vec<String> = Vec::new();
118    for (type_name, schema) in overlays {
119        let type_name = type_name.as_ref();
120        store.apply_overlay(type_name, &schema)?;
121        if !type_names.contains(&type_name.to_string()) {
122            type_names.push(type_name.to_string());
123        }
124    }
125    build_validated_overlay_catalog(&store, &type_names)
126}
127
128/// Builds metadata only after validating references against the final merged
129/// store. Per-overlay application permits forward references because a later
130/// overlay may supply the target; anything still dangling here cannot resolve.
131fn build_validated_overlay_catalog(
132    store: &CompiledSchemaStore,
133    type_names: &[String],
134) -> Result<OverlayCatalog, SchemaOverlayError> {
135    for type_name in type_names {
136        if let Some(schema) = store.get(type_name)
137            && let Some((path, target)) = overlay::find_dangling_refs(schema).into_iter().next()
138        {
139            return Err(SchemaOverlayError::DanglingRef { type_name: type_name.clone(), path, target });
140        }
141    }
142    Ok(OverlayCatalog::from_store(store, type_names))
143}
144
145pub struct SchemaValidator {
146    store: CompiledSchemaStore,
147    catalog: OverlayCatalog,
148    init_metric: PhaseMetric,
149}
150
151impl Default for SchemaValidator {
152    fn default() -> Self {
153        Self::new_default()
154    }
155}
156
157impl SchemaValidator {
158    /// Constructs a [`SchemaValidator`] from a [`SchemaValidatorConfig`],
159    /// resolving and applying each additional schema once. The resulting
160    /// [`OverlayCatalog`] is stored and accessible via [`Self::overlay_catalog`].
161    ///
162    /// This is the canonical construction path: bindings, the CLI, and library
163    /// embedders all go through this method. A configured overlay behaves
164    /// identically no matter which front end supplied it.
165    ///
166    /// For call sites that need no overlay schemas, [`SchemaValidator::default()`]
167    /// provides an infallible, zero-config constructor.
168    ///
169    /// Returns an error when an overlay is malformed.
170    pub fn new(config: SchemaValidatorConfig) -> Result<Self, SchemaValidatorConfigError> {
171        if config.additional_schemas.is_empty() {
172            return Ok(Self::new_default());
173        }
174        let overlays: Vec<(String, serde_json::Value)> =
175            config.additional_schemas.iter().map(|s| s.resolve()).collect::<Result<Vec<_>, _>>()?;
176        Ok(Self::try_with_additional_schemas(overlays)?)
177    }
178
179    /// Infallible constructor with no overlay schemas - used by `Default`.
180    fn new_default() -> Self {
181        let start = web_time::Instant::now();
182        let store = CompiledSchemaStore::new();
183        Self::finish(store, OverlayCatalog::default(), 0, start)
184    }
185
186    /// Construct a validator whose schema store has `additional_schemas` merged
187    /// on top of the bundled schemas.
188    ///
189    /// Each item is a `(type_name, schema)` pair where `schema` is an
190    /// already-parsed CloudFormation resource provider schema (registry JSON, the
191    /// same shape the build-time schema compiler consumes) and `type_name` is the
192    /// resource type it applies to. Overlays are applied in order, so a later
193    /// overlay sees the result of the earlier ones. The
194    /// [`crate::overlay`] module documents the merge model and its scope
195    /// limits.
196    ///
197    /// Fails, without building a validator, when an overlay is not a JSON object,
198    /// carries no type name, nests too deeply, defines a cyclic `$ref` graph, or
199    /// would change nothing. An overlay for a type the bundled schemas do not
200    /// contain is registered as a new type and logged, since that is also what a
201    /// misspelled type name looks like.
202    pub fn try_with_additional_schemas<I, S>(additional_schemas: I) -> Result<Self, SchemaOverlayError>
203    where
204        I: IntoIterator<Item = (S, serde_json::Value)>,
205        S: AsRef<str>,
206    {
207        let start = web_time::Instant::now();
208        let mut store = CompiledSchemaStore::new();
209        let mut applied = 0usize;
210        let mut type_names: Vec<String> = Vec::new();
211        for (type_name, schema) in additional_schemas {
212            let type_name = type_name.as_ref();
213            if store.apply_overlay(type_name, &schema)? == OverlayOutcome::Inserted {
214                if should_warn_for_inserted_type(type_name) {
215                    warn!(
216                        "Additional schema for '{type_name}' matches no bundled resource type; registering it as a \
217                         new type. Check the type name if this was meant to extend an existing type."
218                    );
219                } else {
220                    info!("Additional schema for '{type_name}' registered as a new type (private/custom provider).");
221                }
222            }
223            if !type_names.contains(&type_name.to_string()) {
224                type_names.push(type_name.to_string());
225            }
226            applied += 1;
227        }
228        let catalog = build_validated_overlay_catalog(&store, &type_names)?;
229        Ok(Self::finish(store, catalog, applied, start))
230    }
231
232    /// The overlay catalog derived from the final merged schema store. Empty
233    /// when no additional schemas were applied. Rule engines use this to access
234    /// overlay-aware metadata without rebuilding the store.
235    #[doc(hidden)]
236    pub fn overlay_catalog(&self) -> &OverlayCatalog {
237        &self.catalog
238    }
239
240    fn finish(
241        store: CompiledSchemaStore,
242        catalog: OverlayCatalog,
243        overlays_applied: usize,
244        start: web_time::Instant,
245    ) -> Self {
246        let init_metric = phase_metric(start);
247        if overlays_applied > 0 {
248            info!(
249                "SchemaValidator initialized: {} schemas loaded, {overlays_applied} overlay schema(s) applied",
250                store.len()
251            );
252        } else {
253            info!("SchemaValidator initialized: {} schemas loaded", store.len());
254        }
255        SchemaValidator { store, catalog, init_metric }
256    }
257
258    pub fn init_metric(&self) -> &PhaseMetric {
259        &self.init_metric
260    }
261
262    /// Validates every resource. `region` is the configured AWS region, or `None`
263    /// when the caller supplied none - in which case region-scoped checks widen to
264    /// the union of all regions (a resource type or enum value is flagged only when
265    /// it is unavailable in every region) rather than assuming a default region.
266    pub fn validate(&self, model: &Arc<SemanticModel>, region: Option<&str>) -> SchemaValidationResult {
267        let start = web_time::Instant::now();
268        let diagnostics = validate::validate_all_resources(&self.store, model, region);
269        let metric = phase_metric(start);
270        SchemaValidationResult { diagnostics, metric }
271    }
272
273    pub fn enrich_context(&self, diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
274        validate::enrich_schema_context(diagnostics, &self.store, model);
275    }
276
277    pub fn schema_count(&self) -> usize {
278        self.store.len()
279    }
280
281    pub fn list_rules(&self) -> Vec<RuleInfo> {
282        // Every rule ID the schema-validator can emit (see src/validate.rs).
283        const SCHEMA_RULE_IDS: &[&str] = &[
284            "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "W3030", "F3031", "F3032",
285            "F3033", "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "F3006", "E9006", "E2531", "E2533", "E3710",
286            "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002",
287        ];
288        SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect()
289    }
290}
291
292/// Whether inserting a previously unknown type should be logged at `warn` level.
293///
294/// Unknown AWS::* types are likely a misspelled type name - warn so the caller
295/// notices. Private/custom provider types (e.g. `MyOrg::Network::Firewall`) are
296/// intentionally new and insertion is the expected outcome - info is sufficient.
297pub(crate) fn should_warn_for_inserted_type(type_name: &str) -> bool {
298    type_name.starts_with("AWS::")
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn new_without_overlays_matches_default_construction() {
307        let config = SchemaValidatorConfig::default();
308        let validator = SchemaValidator::new(config).expect("an empty config builds");
309        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
310    }
311
312    #[test]
313    fn new_applies_configured_overlays() {
314        let config = SchemaValidatorConfig {
315            additional_schemas: vec![AdditionalSchemaSource {
316                type_name: None,
317                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
318                    .into(),
319            }],
320        };
321        let validator = SchemaValidator::new(config).expect("a valid overlay builds");
322        let template = br#"
323Resources:
324  Fn:
325    Type: AWS::Lambda::Function
326    Properties:
327      Code:
328        ZipFile: "exports.handler = async () => {};"
329      Role: arn:aws:iam::123456789012:role/lambda-role
330      Runtime: nodejs18.x
331      Handler: index.handler
332      TestForOverride: enabled
333"#;
334        let model = Arc::new(SemanticModel::from_bytes(template).expect("template parses"));
335        let diagnostics = validator.validate(&model, Some("us-east-1")).diagnostics;
336        assert!(
337            !diagnostics.iter().any(|d| d.rule_id == "F3002"),
338            "the configured overlay must reach schema validation, got: {:?}",
339            diagnostics.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
340        );
341    }
342
343    #[test]
344    fn new_reports_a_malformed_overlay() {
345        let config = SchemaValidatorConfig {
346            additional_schemas: vec![AdditionalSchemaSource {
347                type_name: Some("AWS::Test::Cycle".into()),
348                schema: r##"{"properties":{"P":{"$ref":"#/definitions/D"}},"definitions":{"D":{"$ref":"#/definitions/D"}}}"##
349                    .into(),
350            }],
351        };
352        let message = match SchemaValidator::new(config) {
353            Err(e) => e.to_string(),
354            Ok(_) => panic!("a cyclic overlay must fail construction"),
355        };
356        assert!(message.contains("cycle"), "the error must describe the cycle, got: {message}");
357    }
358
359    #[test]
360    fn config_builds_validator_with_overlays() {
361        let config = SchemaValidatorConfig {
362            additional_schemas: vec![AdditionalSchemaSource {
363                type_name: None,
364                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
365                    .into(),
366            }],
367        };
368        let validator = SchemaValidator::new(config).expect("valid config builds");
369        assert!(
370            !validator.overlay_catalog().is_empty(),
371            "validator built from SchemaValidatorConfig must expose a populated catalog"
372        );
373        assert!(
374            validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()),
375            "catalog must include the overlaid type"
376        );
377    }
378
379    #[test]
380    fn config_empty_builds_default_validator() {
381        let config = SchemaValidatorConfig::default();
382        let validator = SchemaValidator::new(config).expect("empty config builds");
383        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
384        assert!(validator.overlay_catalog().is_empty());
385    }
386
387    #[test]
388    fn should_warn_for_aws_types_but_not_private_providers() {
389        assert!(
390            should_warn_for_inserted_type("AWS::S3::NewBucket"),
391            "unknown AWS::* types should warn - likely typo or unpublished"
392        );
393        assert!(
394            should_warn_for_inserted_type("AWS::EC2::Instance"),
395            "AWS namespace insertion warns regardless of whether the type exists"
396        );
397        assert!(
398            !should_warn_for_inserted_type("MyOrg::Network::Firewall"),
399            "private/custom provider types are supported and should not warn"
400        );
401        assert!(
402            !should_warn_for_inserted_type("Acme::Storage::ObjectStore"),
403            "third-party provider types are supported and should not warn"
404        );
405    }
406}