Skip to main content

schema_validator/
lib.rs

1pub mod catalog;
2pub(crate) mod compiled;
3pub(crate) mod convert;
4pub mod overlay;
5pub mod resource_schema;
6pub mod store;
7pub mod validate;
8
9#[cfg(feature = "uniffi-bindings")]
10uniffi::setup_scaffolding!();
11
12pub use catalog::OverlayCatalog;
13pub use data_source::{AdditionalSchemaSource, SchemaSourceError};
14pub use overlay::{MAX_OVERLAY_DEPTH, SchemaOverlayError};
15pub use resource_schema::{PropertyValueType, ResourceSchemaMetadata};
16pub use store::{CompiledSchemaStore, OverlayOutcome};
17
18/// Eagerly decompress all embedded data LazyLocks. Intended to be called once at
19/// process/module start in environments that benefit from front-loading cost
20pub fn prewarm_embedded_data() {
21    data_source::embedded::warm_all();
22}
23
24pub use data_source::types::SchemaMetadataCatalog;
25use data_source::types::SchemaMetadataDocument;
26use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
27use log::{info, warn};
28use rules::{RuleInfo, lookup_rule};
29use serde::{Deserialize, Serialize};
30use std::sync::{Arc, OnceLock};
31use template_model::SemanticModel;
32
33pub struct SchemaValidationResult {
34    pub diagnostics: Vec<Diagnostic>,
35    pub metric: PhaseMetric,
36}
37
38/// Configuration for constructing a [`SchemaValidator`] with optional overlay
39/// schemas. Bindings and the CLI use this to build the validator separately from
40/// the rule engine.
41#[derive(Default, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
43#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
44#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
45#[serde(rename_all = "camelCase")]
46pub struct SchemaValidatorConfig {
47    /// Additional CloudFormation resource provider schemas to merge on top of the
48    /// bundled schemas before schema validation.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
51    pub additional_schemas: Vec<AdditionalSchemaSource>,
52}
53
54impl SchemaValidatorConfig {
55    /// Starts from the default configuration (no overlays).
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Adds resource provider schemas to overlay on the bundled ones.
61    pub fn with_additional_schemas(mut self, schemas: impl IntoIterator<Item = AdditionalSchemaSource>) -> Self {
62        self.additional_schemas.extend(schemas);
63        self
64    }
65}
66
67/// Error reported when schema-validator construction from config fails.
68#[derive(Debug)]
69pub enum SchemaValidatorConfigError {
70    /// An overlay schema source failed to resolve (bad JSON, missing type name, etc.).
71    Source(SchemaSourceError),
72    /// An overlay schema failed to apply (cycle, depth limit, etc.).
73    Overlay(SchemaOverlayError),
74}
75
76impl std::fmt::Display for SchemaValidatorConfigError {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            SchemaValidatorConfigError::Source(e) => write!(f, "{e}"),
80            SchemaValidatorConfigError::Overlay(e) => {
81                write!(f, "Failed to apply an additional schema: {e}")
82            }
83        }
84    }
85}
86
87impl std::error::Error for SchemaValidatorConfigError {
88    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
89        match self {
90            SchemaValidatorConfigError::Source(e) => Some(e),
91            SchemaValidatorConfigError::Overlay(e) => Some(e),
92        }
93    }
94}
95
96impl From<SchemaSourceError> for SchemaValidatorConfigError {
97    fn from(e: SchemaSourceError) -> Self {
98        SchemaValidatorConfigError::Source(e)
99    }
100}
101
102impl From<SchemaOverlayError> for SchemaValidatorConfigError {
103    fn from(e: SchemaOverlayError) -> Self {
104        SchemaValidatorConfigError::Overlay(e)
105    }
106}
107
108/// Builds an [`OverlayCatalog`] from resolved schema overlay pairs. This is the
109/// internal helper that engines use for standalone construction when they need to
110/// derive a catalog from additional schemas without constructing a full
111/// [`SchemaValidator`].
112///
113/// Returns `Ok(catalog)` where `catalog.is_empty()` when `overlays` is empty.
114#[doc(hidden)]
115pub fn build_overlay_catalog<I, S>(overlays: I) -> Result<OverlayCatalog, SchemaOverlayError>
116where
117    I: IntoIterator<Item = (S, serde_json::Value)>,
118    S: AsRef<str>,
119{
120    let mut store = CompiledSchemaStore::new();
121    let mut type_names: Vec<String> = Vec::new();
122    for (type_name, schema) in overlays {
123        let type_name = type_name.as_ref();
124        store.apply_overlay(type_name, &schema)?;
125        if !type_names.contains(&type_name.to_string()) {
126            type_names.push(type_name.to_string());
127        }
128    }
129    build_validated_overlay_catalog(&store, &type_names)
130}
131
132/// Builds metadata only after validating references against the final merged
133/// store. Per-overlay application permits forward references because a later
134/// overlay may supply the target; anything still dangling here cannot resolve.
135fn build_validated_overlay_catalog(
136    store: &CompiledSchemaStore,
137    type_names: &[String],
138) -> Result<OverlayCatalog, SchemaOverlayError> {
139    for type_name in type_names {
140        if let Some(schema) = store.get(type_name)
141            && let Some((path, target)) = overlay::find_dangling_refs(schema).into_iter().next()
142        {
143            return Err(SchemaOverlayError::DanglingRef { type_name: type_name.clone(), path, target });
144        }
145    }
146    Ok(OverlayCatalog::from_store(store, type_names))
147}
148
149/// Error reported when the shared schema-metadata catalog cannot be produced.
150#[derive(Debug)]
151pub enum SchemaMetadataError {
152    /// The embedded `schema_metadata` artifact is not valid JSON for the model.
153    Parse(serde_json::Error),
154    /// The embedded `schema_metadata` artifact is present but empty.
155    Empty,
156}
157
158impl std::fmt::Display for SchemaMetadataError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            SchemaMetadataError::Parse(e) => write!(f, "Failed to parse embedded schema_metadata: {e}"),
162            SchemaMetadataError::Empty => write!(f, "Embedded schema_metadata must not be empty"),
163        }
164    }
165}
166
167impl std::error::Error for SchemaMetadataError {
168    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
169        match self {
170            SchemaMetadataError::Parse(e) => Some(e),
171            SchemaMetadataError::Empty => None,
172        }
173    }
174}
175
176/// The process-wide base schema-metadata catalog: the bundled artifact parsed
177/// once into the shared typed model and handed out by reference.
178///
179/// Parsing is lazy and fallible - schema-only construction never triggers it, a
180/// caller that needs the catalog gets the same [`Arc`] every other default
181/// caller shares, and a corrupt embedded artifact surfaces as an error rather
182/// than a panic or a plausible-looking empty catalog.
183pub fn shared_base_schema_metadata() -> Result<Arc<SchemaMetadataCatalog>, SchemaMetadataError> {
184    static BASE: OnceLock<Arc<SchemaMetadataCatalog>> = OnceLock::new();
185    if let Some(existing) = BASE.get() {
186        return Ok(existing.clone());
187    }
188    let document: SchemaMetadataDocument =
189        serde_json::from_slice(&data_source::embedded::SCHEMA_METADATA_BYTES).map_err(SchemaMetadataError::Parse)?;
190    if document.schema_metadata.is_empty() {
191        return Err(SchemaMetadataError::Empty);
192    }
193    // On a construction race the redundant parse is discarded and every caller
194    // still observes the single shared value the winner installed.
195    Ok(BASE.get_or_init(|| Arc::new(document.schema_metadata)).clone())
196}
197
198/// Produce the schema-metadata catalog an engine should use for a given overlay.
199///
200/// With no overlaid metadata this returns the shared global base [`Arc`] itself,
201/// so every default engine and validator in the process shares one parsed
202/// catalog. With overlays it clones the base once and replaces the entry for
203/// each overlaid type, preserving the base for every untouched type.
204#[doc(hidden)]
205pub fn schema_metadata_catalog_with_overlays(
206    overlay: &OverlayCatalog,
207) -> Result<Arc<SchemaMetadataCatalog>, SchemaMetadataError> {
208    let base = shared_base_schema_metadata()?;
209    if overlay.schema_metadata.is_empty() {
210        return Ok(base);
211    }
212    let mut merged = (*base).clone();
213    for (type_name, entry) in &overlay.schema_metadata {
214        merged.insert(type_name.clone(), entry.clone());
215    }
216    Ok(Arc::new(merged))
217}
218
219pub struct SchemaValidator {
220    store: CompiledSchemaStore,
221    catalog: OverlayCatalog,
222    init_metric: PhaseMetric,
223    /// The shared schema-metadata catalog for this validator, resolved lazily on
224    /// first request. A default validator resolves to the global base [`Arc`]; an
225    /// overlay validator clones the base and replaces its overlaid entries once,
226    /// then hands the same [`Arc`] to every engine built from it.
227    metadata_catalog: OnceLock<Arc<SchemaMetadataCatalog>>,
228}
229
230impl Default for SchemaValidator {
231    fn default() -> Self {
232        Self::new_default()
233    }
234}
235
236impl SchemaValidator {
237    /// Constructs a [`SchemaValidator`] from a [`SchemaValidatorConfig`],
238    /// resolving and applying each additional schema once. The resulting
239    /// [`OverlayCatalog`] is stored and accessible via [`Self::overlay_catalog`].
240    ///
241    /// This is the canonical construction path: bindings, the CLI, and library
242    /// embedders all go through this method. A configured overlay behaves
243    /// identically no matter which front end supplied it.
244    ///
245    /// For call sites that need no overlay schemas, [`SchemaValidator::default()`]
246    /// provides an infallible, zero-config constructor.
247    ///
248    /// Returns an error when an overlay is malformed.
249    pub fn new(config: SchemaValidatorConfig) -> Result<Self, SchemaValidatorConfigError> {
250        if config.additional_schemas.is_empty() {
251            return Ok(Self::new_default());
252        }
253        let overlays: Vec<(String, serde_json::Value)> =
254            config.additional_schemas.iter().map(|s| s.resolve()).collect::<Result<Vec<_>, _>>()?;
255        Ok(Self::try_with_additional_schemas(overlays)?)
256    }
257
258    /// Infallible constructor with no overlay schemas - used by `Default`.
259    fn new_default() -> Self {
260        let start = web_time::Instant::now();
261        let store = CompiledSchemaStore::new();
262        Self::finish(store, OverlayCatalog::default(), 0, start)
263    }
264
265    /// Construct a validator whose schema store has `additional_schemas` merged
266    /// on top of the bundled schemas.
267    ///
268    /// Each item is a `(type_name, schema)` pair where `schema` is an
269    /// already-parsed CloudFormation resource provider schema (registry JSON, the
270    /// same shape the build-time schema compiler consumes) and `type_name` is the
271    /// resource type it applies to. Overlays are applied in order, so a later
272    /// overlay sees the result of the earlier ones. The
273    /// [`crate::overlay`] module documents the merge model and its scope
274    /// limits.
275    ///
276    /// Fails, without building a validator, when an overlay is not a JSON object,
277    /// carries no type name, nests too deeply, defines a cyclic `$ref` graph, or
278    /// would change nothing. An overlay for a type the bundled schemas do not
279    /// contain is registered as a new type and logged, since that is also what a
280    /// misspelled type name looks like.
281    pub fn try_with_additional_schemas<I, S>(additional_schemas: I) -> Result<Self, SchemaOverlayError>
282    where
283        I: IntoIterator<Item = (S, serde_json::Value)>,
284        S: AsRef<str>,
285    {
286        let start = web_time::Instant::now();
287        let mut store = CompiledSchemaStore::new();
288        let mut applied = 0usize;
289        let mut type_names: Vec<String> = Vec::new();
290        for (type_name, schema) in additional_schemas {
291            let type_name = type_name.as_ref();
292            if store.apply_overlay(type_name, &schema)? == OverlayOutcome::Inserted {
293                if should_warn_for_inserted_type(type_name) {
294                    warn!(
295                        "Additional schema for '{type_name}' matches no bundled resource type; registering it as a \
296                         new type. Check the type name if this was meant to extend an existing type."
297                    );
298                } else {
299                    info!("Additional schema for '{type_name}' registered as a new type (private/custom provider).");
300                }
301            }
302            if !type_names.contains(&type_name.to_string()) {
303                type_names.push(type_name.to_string());
304            }
305            applied += 1;
306        }
307        let catalog = build_validated_overlay_catalog(&store, &type_names)?;
308        Ok(Self::finish(store, catalog, applied, start))
309    }
310
311    /// The overlay catalog derived from the final merged schema store. Empty
312    /// when no additional schemas were applied. Rule engines use this to access
313    /// overlay-aware metadata without rebuilding the store.
314    #[doc(hidden)]
315    pub fn overlay_catalog(&self) -> &OverlayCatalog {
316        &self.catalog
317    }
318
319    /// The shared schema-metadata catalog for this validator, resolved on first
320    /// call and cached thereafter.
321    ///
322    /// A default validator hands back the process-wide base [`Arc`], so every
323    /// default consumer shares one parsed catalog. An overlay validator clones
324    /// the base once, replaces its overlaid entries, and returns the same [`Arc`]
325    /// to every later caller - so a Rego and a CEL engine built from one
326    /// validator share the identical catalog rather than each rebuilding it.
327    ///
328    /// Fallible: a corrupt embedded artifact surfaces as an error instead of a
329    /// panic. Construction never calls this, so a validator that is only used for
330    /// schema validation never parses the metadata.
331    #[doc(hidden)]
332    pub fn schema_metadata_catalog(&self) -> Result<Arc<SchemaMetadataCatalog>, SchemaMetadataError> {
333        if let Some(existing) = self.metadata_catalog.get() {
334            return Ok(existing.clone());
335        }
336        let catalog = schema_metadata_catalog_with_overlays(&self.catalog)?;
337        Ok(self.metadata_catalog.get_or_init(|| catalog).clone())
338    }
339
340    fn finish(
341        store: CompiledSchemaStore,
342        catalog: OverlayCatalog,
343        overlays_applied: usize,
344        start: web_time::Instant,
345    ) -> Self {
346        let init_metric = phase_metric(start);
347        if overlays_applied > 0 {
348            info!(
349                "SchemaValidator initialized: {} schemas loaded, {overlays_applied} overlay schema(s) applied",
350                store.len()
351            );
352        } else {
353            info!("SchemaValidator initialized: {} schemas loaded", store.len());
354        }
355        SchemaValidator { store, catalog, init_metric, metadata_catalog: OnceLock::new() }
356    }
357
358    pub fn init_metric(&self) -> &PhaseMetric {
359        &self.init_metric
360    }
361
362    /// Validates every resource. `region` is the configured AWS region, or `None`
363    /// when the caller supplied none - in which case region-scoped checks widen to
364    /// the union of all regions (a resource type or enum value is flagged only when
365    /// it is unavailable in every region) rather than assuming a default region.
366    pub fn validate(&self, model: &Arc<SemanticModel>, region: Option<&str>) -> SchemaValidationResult {
367        let start = web_time::Instant::now();
368        let diagnostics = validate::validate_all_resources(&self.store, model, region);
369        let metric = phase_metric(start);
370        SchemaValidationResult { diagnostics, metric }
371    }
372
373    pub fn enrich_context(&self, diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
374        validate::enrich_schema_context(diagnostics, &self.store, model);
375    }
376
377    pub fn schema_count(&self) -> usize {
378        self.store.len()
379    }
380
381    /// Returns the schema fields needed to map request parameters to one
382    /// CloudFormation resource type, including configured schema overlays.
383    pub fn resource_schema_metadata(&self, type_name: &str) -> Option<ResourceSchemaMetadata> {
384        self.store.get(type_name).map(ResourceSchemaMetadata::from_compiled)
385    }
386
387    /// Whether this validator has a bundled or caller-provided schema for a
388    /// CloudFormation resource type.
389    pub fn has_resource_type(&self, type_name: &str) -> bool {
390        self.store.get(type_name).is_some()
391    }
392
393    /// Iterates every bundled and caller-provided CloudFormation resource type.
394    pub fn resource_type_names(&self) -> impl Iterator<Item = &str> {
395        self.store.type_names()
396    }
397
398    pub fn list_rules(&self) -> Vec<RuleInfo> {
399        // Every rule ID the schema-validator can emit (see src/validate.rs).
400        const SCHEMA_RULE_IDS: &[&str] = &[
401            "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "W3030", "F3031", "F3032",
402            "F3033", "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "F3006", "E9006", "E2531", "E2533", "E3710",
403            "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002",
404        ];
405        SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect()
406    }
407}
408
409/// Whether inserting a previously unknown type should be logged at `warn` level.
410///
411/// Unknown AWS::* types are likely a misspelled type name - warn so the caller
412/// notices. Private/custom provider types (e.g. `MyOrg::Network::Firewall`) are
413/// intentionally new and insertion is the expected outcome - info is sufficient.
414pub(crate) fn should_warn_for_inserted_type(type_name: &str) -> bool {
415    type_name.starts_with("AWS::")
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn new_without_overlays_matches_default_construction() {
424        let config = SchemaValidatorConfig::default();
425        let validator = SchemaValidator::new(config).expect("an empty config builds");
426        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
427    }
428
429    #[test]
430    fn new_applies_configured_overlays() {
431        let config = SchemaValidatorConfig {
432            additional_schemas: vec![AdditionalSchemaSource {
433                type_name: None,
434                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
435                    .into(),
436            }],
437        };
438        let validator = SchemaValidator::new(config).expect("a valid overlay builds");
439        let template = br#"
440Resources:
441  Fn:
442    Type: AWS::Lambda::Function
443    Properties:
444      Code:
445        ZipFile: "exports.handler = async () => {};"
446      Role: arn:aws:iam::123456789012:role/lambda-role
447      Runtime: nodejs18.x
448      Handler: index.handler
449      TestForOverride: enabled
450"#;
451        let model = Arc::new(SemanticModel::from_bytes(template).expect("template parses"));
452        let diagnostics = validator.validate(&model, Some("us-east-1")).diagnostics;
453        assert!(
454            !diagnostics.iter().any(|d| d.rule_id == "F3002"),
455            "the configured overlay must reach schema validation, got: {:?}",
456            diagnostics.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
457        );
458    }
459
460    #[test]
461    fn new_reports_a_malformed_overlay() {
462        let config = SchemaValidatorConfig {
463            additional_schemas: vec![AdditionalSchemaSource {
464                type_name: Some("AWS::Test::Cycle".into()),
465                schema: r##"{"properties":{"P":{"$ref":"#/definitions/D"}},"definitions":{"D":{"$ref":"#/definitions/D"}}}"##
466                    .into(),
467            }],
468        };
469        let message = match SchemaValidator::new(config) {
470            Err(e) => e.to_string(),
471            Ok(_) => panic!("a cyclic overlay must fail construction"),
472        };
473        assert!(message.contains("cycle"), "the error must describe the cycle, got: {message}");
474    }
475
476    #[test]
477    fn config_builds_validator_with_overlays() {
478        let config = SchemaValidatorConfig {
479            additional_schemas: vec![AdditionalSchemaSource {
480                type_name: None,
481                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
482                    .into(),
483            }],
484        };
485        let validator = SchemaValidator::new(config).expect("valid config builds");
486        assert!(
487            !validator.overlay_catalog().is_empty(),
488            "validator built from SchemaValidatorConfig must expose a populated catalog"
489        );
490        assert!(
491            validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()),
492            "catalog must include the overlaid type"
493        );
494    }
495
496    #[test]
497    fn config_empty_builds_default_validator() {
498        let config = SchemaValidatorConfig::default();
499        let validator = SchemaValidator::new(config).expect("empty config builds");
500        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
501        assert!(validator.overlay_catalog().is_empty());
502    }
503
504    #[test]
505    fn schema_metadata_is_lazy_and_shared_by_default_validators() {
506        let first = SchemaValidator::default();
507        let second = SchemaValidator::default();
508        assert!(first.metadata_catalog.get().is_none(), "construction must not parse rule metadata");
509        assert!(second.metadata_catalog.get().is_none(), "construction must not parse rule metadata");
510
511        let first_catalog = first.schema_metadata_catalog().expect("embedded metadata parses");
512        let first_again = first.schema_metadata_catalog().expect("cached metadata remains available");
513        let second_catalog = second.schema_metadata_catalog().expect("embedded metadata is shared");
514
515        assert!(Arc::ptr_eq(&first_catalog, &first_again), "one validator must reuse its cached Arc");
516        assert!(Arc::ptr_eq(&first_catalog, &second_catalog), "default validators must share the process-wide Arc");
517    }
518
519    #[test]
520    fn overlay_schema_metadata_is_merged_once_per_validator() {
521        let validator = SchemaValidator::try_with_additional_schemas([(
522            "AWS::S3::Bucket",
523            serde_json::json!({"properties": {"OverlayProperty": {"type": "string"}}}),
524        )])
525        .expect("overlay applies");
526        assert!(validator.metadata_catalog.get().is_none(), "overlay construction must leave rule metadata lazy");
527
528        let base = shared_base_schema_metadata().expect("base metadata parses");
529        let merged = validator.schema_metadata_catalog().expect("overlay metadata merges");
530        let merged_again = validator.schema_metadata_catalog().expect("merged metadata remains available");
531
532        assert!(!Arc::ptr_eq(&base, &merged), "an overlay must not mutate the shared base catalog");
533        assert!(Arc::ptr_eq(&merged, &merged_again), "an overlay validator must cache one merged Arc");
534        assert!(
535            merged["AWS::S3::Bucket"].properties.contains(&"OverlayProperty".to_string()),
536            "the shared engine catalog must contain the overlay-derived property"
537        );
538        assert!(
539            !base["AWS::S3::Bucket"].properties.contains(&"OverlayProperty".to_string()),
540            "the process-wide base catalog must remain unchanged"
541        );
542    }
543
544    #[test]
545    fn should_warn_for_aws_types_but_not_private_providers() {
546        assert!(
547            should_warn_for_inserted_type("AWS::S3::NewBucket"),
548            "unknown AWS::* types should warn - likely typo or unpublished"
549        );
550        assert!(
551            should_warn_for_inserted_type("AWS::EC2::Instance"),
552            "AWS namespace insertion warns regardless of whether the type exists"
553        );
554        assert!(
555            !should_warn_for_inserted_type("MyOrg::Network::Firewall"),
556            "private/custom provider types are supported and should not warn"
557        );
558        assert!(
559            !should_warn_for_inserted_type("Acme::Storage::ObjectStore"),
560            "third-party provider types are supported and should not warn"
561        );
562    }
563}