Skip to main content

schema_validator/
lib.rs

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