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::{GetattData, 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
219/// Error reported when the shared GetAtt attribute table cannot be produced.
220#[derive(Debug)]
221pub enum GetattDataError {
222    /// The embedded `getatt_attributes` artifact is not valid JSON for the model.
223    Parse(serde_json::Error),
224    /// The embedded `getatt_attributes` artifact is present but empty.
225    Empty,
226}
227
228impl std::fmt::Display for GetattDataError {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        match self {
231            GetattDataError::Parse(e) => write!(f, "Failed to parse embedded getatt_attributes: {e}"),
232            GetattDataError::Empty => write!(f, "Embedded getatt_attributes must not be empty"),
233        }
234    }
235}
236
237impl std::error::Error for GetattDataError {
238    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
239        match self {
240            GetattDataError::Parse(e) => Some(e),
241            GetattDataError::Empty => None,
242        }
243    }
244}
245
246/// The process-wide base GetAtt attribute table: the bundled `getatt_attributes`
247/// artifact parsed once and handed out by reference.
248///
249/// The schema store's Ref/GetAtt return types and both rule engines' GetAtt
250/// lookups read this one table, so a process holds a single copy however many
251/// validators and engines it constructs. Every consumer parses it at
252/// construction, never on the first template. A corrupt or empty embedded
253/// artifact surfaces as an error rather than a panic.
254pub fn shared_base_getatt_data() -> Result<Arc<GetattData>, GetattDataError> {
255    static BASE: OnceLock<Arc<GetattData>> = OnceLock::new();
256    if let Some(existing) = BASE.get() {
257        return Ok(existing.clone());
258    }
259    let data: GetattData =
260        serde_json::from_slice(&data_source::embedded::GETATT_ATTRIBUTES_BYTES).map_err(GetattDataError::Parse)?;
261    if data.getatt_attributes.is_empty() || data.getatt_attribute_types.is_empty() {
262        return Err(GetattDataError::Empty);
263    }
264    // On a construction race the redundant parse is discarded and every caller
265    // still observes the single shared value the winner installed.
266    Ok(BASE.get_or_init(|| Arc::new(data)).clone())
267}
268
269/// Produce the GetAtt attribute table an engine should use for a given overlay.
270///
271/// With no overlaid types this returns the shared base [`Arc`] itself. With
272/// overlays it clones the base once and layers each overlaid type's attributes
273/// and attribute return types on top, so an overlay can add attributes to a
274/// bundled type or correct a return type without losing the bundled entries.
275#[doc(hidden)]
276pub fn getatt_data_with_overlays(overlay: &OverlayCatalog) -> Result<Arc<GetattData>, GetattDataError> {
277    let base = shared_base_getatt_data()?;
278    if overlay.getatt_attributes.is_empty() && overlay.getatt_attribute_types.is_empty() {
279        return Ok(base);
280    }
281    let mut merged = (*base).clone();
282    for (type_name, attrs) in &overlay.getatt_attributes {
283        let entry = merged.getatt_attributes.entry(type_name.clone()).or_default();
284        entry.extend(attrs.iter().cloned());
285        entry.sort();
286        entry.dedup();
287    }
288    for (type_name, attr_types) in &overlay.getatt_attribute_types {
289        let entry = merged.getatt_attribute_types.entry(type_name.clone()).or_default();
290        for (attr, return_type) in attr_types {
291            entry.insert(attr.clone(), return_type.clone());
292        }
293    }
294    Ok(Arc::new(merged))
295}
296
297pub struct SchemaValidator {
298    store: CompiledSchemaStore,
299    catalog: OverlayCatalog,
300    init_metric: PhaseMetric,
301    /// The shared schema-metadata catalog for this validator, resolved lazily on
302    /// first request. A default validator resolves to the global base [`Arc`]; an
303    /// overlay validator clones the base and replaces its overlaid entries once,
304    /// then hands the same [`Arc`] to every engine built from it.
305    metadata_catalog: OnceLock<Arc<SchemaMetadataCatalog>>,
306}
307
308impl Default for SchemaValidator {
309    fn default() -> Self {
310        Self::new_default()
311    }
312}
313
314impl SchemaValidator {
315    /// Constructs a [`SchemaValidator`] from a [`SchemaValidatorConfig`],
316    /// resolving and applying each additional schema once. The resulting
317    /// [`OverlayCatalog`] is stored and accessible via [`Self::overlay_catalog`].
318    ///
319    /// This is the canonical construction path: bindings, the CLI, and library
320    /// embedders all go through this method. A configured overlay behaves
321    /// identically no matter which front end supplied it.
322    ///
323    /// For call sites that need no overlay schemas, [`SchemaValidator::default()`]
324    /// provides an infallible, zero-config constructor.
325    ///
326    /// Returns an error when an overlay is malformed.
327    pub fn new(config: SchemaValidatorConfig) -> Result<Self, SchemaValidatorConfigError> {
328        if config.additional_schemas.is_empty() {
329            return Ok(Self::new_default());
330        }
331        let overlays: Vec<(String, serde_json::Value)> =
332            config.additional_schemas.iter().map(|s| s.resolve()).collect::<Result<Vec<_>, _>>()?;
333        Ok(Self::try_with_additional_schemas(overlays)?)
334    }
335
336    /// Infallible constructor with no overlay schemas - used by `Default`.
337    fn new_default() -> Self {
338        let start = web_time::Instant::now();
339        let store = CompiledSchemaStore::new();
340        Self::finish(store, OverlayCatalog::default(), 0, start)
341    }
342
343    /// Construct a validator whose schema store has `additional_schemas` merged
344    /// on top of the bundled schemas.
345    ///
346    /// Each item is a `(type_name, schema)` pair where `schema` is an
347    /// already-parsed CloudFormation resource provider schema (registry JSON, the
348    /// same shape the build-time schema compiler consumes) and `type_name` is the
349    /// resource type it applies to. Overlays are applied in order, so a later
350    /// overlay sees the result of the earlier ones. The
351    /// [`crate::overlay`] module documents the merge model and its scope
352    /// limits.
353    ///
354    /// Fails, without building a validator, when an overlay is not a JSON object,
355    /// carries no type name, nests too deeply, defines a cyclic `$ref` graph, or
356    /// would change nothing. An overlay for a type the bundled schemas do not
357    /// contain is registered as a new type and logged, since that is also what a
358    /// misspelled type name looks like.
359    pub fn try_with_additional_schemas<I, S>(additional_schemas: I) -> Result<Self, SchemaOverlayError>
360    where
361        I: IntoIterator<Item = (S, serde_json::Value)>,
362        S: AsRef<str>,
363    {
364        let start = web_time::Instant::now();
365        let mut store = CompiledSchemaStore::new();
366        let mut applied = 0usize;
367        let mut type_names: Vec<String> = Vec::new();
368        for (type_name, schema) in additional_schemas {
369            let type_name = type_name.as_ref();
370            if store.apply_overlay(type_name, &schema)? == OverlayOutcome::Inserted {
371                if should_warn_for_inserted_type(type_name) {
372                    warn!(
373                        "Additional schema for '{type_name}' matches no bundled resource type; registering it as a \
374                         new type. Check the type name if this was meant to extend an existing type."
375                    );
376                } else {
377                    info!("Additional schema for '{type_name}' registered as a new type (private/custom provider).");
378                }
379            }
380            if !type_names.contains(&type_name.to_string()) {
381                type_names.push(type_name.to_string());
382            }
383            applied += 1;
384        }
385        let catalog = build_validated_overlay_catalog(&store, &type_names)?;
386        Ok(Self::finish(store, catalog, applied, start))
387    }
388
389    /// The overlay catalog derived from the final merged schema store. Empty
390    /// when no additional schemas were applied. Rule engines use this to access
391    /// overlay-aware metadata without rebuilding the store.
392    #[doc(hidden)]
393    pub fn overlay_catalog(&self) -> &OverlayCatalog {
394        &self.catalog
395    }
396
397    /// The shared schema-metadata catalog for this validator, resolved on first
398    /// call and cached thereafter.
399    ///
400    /// A default validator hands back the process-wide base [`Arc`], so every
401    /// default consumer shares one parsed catalog. An overlay validator clones
402    /// the base once, replaces its overlaid entries, and returns the same [`Arc`]
403    /// to every later caller - so a Rego and a CEL engine built from one
404    /// validator share the identical catalog rather than each rebuilding it.
405    ///
406    /// Fallible: a corrupt embedded artifact surfaces as an error instead of a
407    /// panic. Construction never calls this, so a validator that is only used for
408    /// schema validation never parses the metadata.
409    #[doc(hidden)]
410    pub fn schema_metadata_catalog(&self) -> Result<Arc<SchemaMetadataCatalog>, SchemaMetadataError> {
411        if let Some(existing) = self.metadata_catalog.get() {
412            return Ok(existing.clone());
413        }
414        let catalog = schema_metadata_catalog_with_overlays(&self.catalog)?;
415        Ok(self.metadata_catalog.get_or_init(|| catalog).clone())
416    }
417
418    fn finish(
419        store: CompiledSchemaStore,
420        catalog: OverlayCatalog,
421        overlays_applied: usize,
422        start: web_time::Instant,
423    ) -> Self {
424        // The fixed format-pattern tables are compiled here rather than on the
425        // first template, so first-template latency matches steady state.
426        validate::prewarm_statics();
427        let init_metric = phase_metric(start);
428        if overlays_applied > 0 {
429            info!(
430                "SchemaValidator initialized: {} schemas loaded, {overlays_applied} overlay schema(s) applied",
431                store.len()
432            );
433        } else {
434            info!("SchemaValidator initialized: {} schemas loaded", store.len());
435        }
436        SchemaValidator { store, catalog, init_metric, metadata_catalog: OnceLock::new() }
437    }
438
439    pub fn init_metric(&self) -> &PhaseMetric {
440        &self.init_metric
441    }
442
443    /// Validates every resource. `region` is the configured AWS region, or `None`
444    /// when the caller supplied none - in which case region-scoped checks widen to
445    /// the union of all regions (a resource type or enum value is flagged only when
446    /// it is unavailable in every region) rather than assuming a default region.
447    pub fn validate(&self, model: &Arc<SemanticModel>, region: Option<&str>) -> SchemaValidationResult {
448        let start = web_time::Instant::now();
449        let diagnostics = validate::validate_all_resources(&self.store, model, region);
450        let metric = phase_metric(start);
451        SchemaValidationResult { diagnostics, metric }
452    }
453
454    pub fn enrich_context(&self, diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
455        validate::enrich_schema_context(diagnostics, &self.store, model);
456    }
457
458    pub fn schema_count(&self) -> usize {
459        self.store.len()
460    }
461
462    /// Returns the schema fields needed to map request parameters to one
463    /// CloudFormation resource type, including configured schema overlays.
464    pub fn resource_schema_metadata(&self, type_name: &str) -> Option<ResourceSchemaMetadata> {
465        self.store.get(type_name).map(ResourceSchemaMetadata::from_compiled)
466    }
467
468    /// Whether this validator has a bundled or caller-provided schema for a
469    /// CloudFormation resource type.
470    pub fn has_resource_type(&self, type_name: &str) -> bool {
471        self.store.get(type_name).is_some()
472    }
473
474    /// Iterates every bundled and caller-provided CloudFormation resource type.
475    pub fn resource_type_names(&self) -> impl Iterator<Item = &str> {
476        self.store.type_names()
477    }
478
479    pub fn list_rules(&self) -> Vec<RuleInfo> {
480        // Every rule ID the schema-validator can emit (see src/validate.rs).
481        const SCHEMA_RULE_IDS: &[&str] = &[
482            "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "W3030", "F3031", "F3032",
483            "F3033", "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "F3006", "E9006", "E2531", "E2533", "E3710",
484            "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002", "E3002", "E3031", "E3032", "E3717",
485            "E3718", "E3719",
486        ];
487        SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect()
488    }
489}
490
491/// Whether inserting a previously unknown type should be logged at `warn` level.
492///
493/// Unknown AWS::* types are likely a misspelled type name - warn so the caller
494/// notices. Private/custom provider types (e.g. `MyOrg::Network::Firewall`) are
495/// intentionally new and insertion is the expected outcome - info is sufficient.
496pub(crate) fn should_warn_for_inserted_type(type_name: &str) -> bool {
497    type_name.starts_with("AWS::")
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn new_without_overlays_matches_default_construction() {
506        let config = SchemaValidatorConfig::default();
507        let validator = SchemaValidator::new(config).expect("an empty config builds");
508        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
509    }
510
511    #[test]
512    fn new_applies_configured_overlays() {
513        let config = SchemaValidatorConfig {
514            additional_schemas: vec![AdditionalSchemaSource {
515                type_name: None,
516                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
517                    .into(),
518            }],
519        };
520        let validator = SchemaValidator::new(config).expect("a valid overlay builds");
521        let template = br#"
522Resources:
523  Fn:
524    Type: AWS::Lambda::Function
525    Properties:
526      Code:
527        ZipFile: "exports.handler = async () => {};"
528      Role: arn:aws:iam::123456789012:role/lambda-role
529      Runtime: nodejs18.x
530      Handler: index.handler
531      TestForOverride: enabled
532"#;
533        let model = Arc::new(SemanticModel::from_bytes(template).expect("template parses"));
534        let diagnostics = validator.validate(&model, Some("us-east-1")).diagnostics;
535        assert!(
536            !diagnostics.iter().any(|d| d.rule_id == "F3002"),
537            "the configured overlay must reach schema validation, got: {:?}",
538            diagnostics.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
539        );
540    }
541
542    #[test]
543    fn new_reports_a_malformed_overlay() {
544        let config = SchemaValidatorConfig {
545            additional_schemas: vec![AdditionalSchemaSource {
546                type_name: Some("AWS::Test::Cycle".into()),
547                schema: r##"{"properties":{"P":{"$ref":"#/definitions/D"}},"definitions":{"D":{"$ref":"#/definitions/D"}}}"##
548                    .into(),
549            }],
550        };
551        let message = match SchemaValidator::new(config) {
552            Err(e) => e.to_string(),
553            Ok(_) => panic!("a cyclic overlay must fail construction"),
554        };
555        assert!(message.contains("cycle"), "the error must describe the cycle, got: {message}");
556    }
557
558    #[test]
559    fn config_builds_validator_with_overlays() {
560        let config = SchemaValidatorConfig {
561            additional_schemas: vec![AdditionalSchemaSource {
562                type_name: None,
563                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
564                    .into(),
565            }],
566        };
567        let validator = SchemaValidator::new(config).expect("valid config builds");
568        assert!(
569            !validator.overlay_catalog().is_empty(),
570            "validator built from SchemaValidatorConfig must expose a populated catalog"
571        );
572        assert!(
573            validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()),
574            "catalog must include the overlaid type"
575        );
576    }
577
578    #[test]
579    fn config_empty_builds_default_validator() {
580        let config = SchemaValidatorConfig::default();
581        let validator = SchemaValidator::new(config).expect("empty config builds");
582        assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
583        assert!(validator.overlay_catalog().is_empty());
584    }
585
586    #[test]
587    fn schema_metadata_is_lazy_and_shared_by_default_validators() {
588        let first = SchemaValidator::default();
589        let second = SchemaValidator::default();
590        assert!(first.metadata_catalog.get().is_none(), "construction must not parse rule metadata");
591        assert!(second.metadata_catalog.get().is_none(), "construction must not parse rule metadata");
592
593        let first_catalog = first.schema_metadata_catalog().expect("embedded metadata parses");
594        let first_again = first.schema_metadata_catalog().expect("cached metadata remains available");
595        let second_catalog = second.schema_metadata_catalog().expect("embedded metadata is shared");
596
597        assert!(Arc::ptr_eq(&first_catalog, &first_again), "one validator must reuse its cached Arc");
598        assert!(Arc::ptr_eq(&first_catalog, &second_catalog), "default validators must share the process-wide Arc");
599    }
600
601    #[test]
602    fn overlay_schema_metadata_is_merged_once_per_validator() {
603        let validator = SchemaValidator::try_with_additional_schemas([(
604            "AWS::S3::Bucket",
605            serde_json::json!({"properties": {"OverlayProperty": {"type": "string"}}}),
606        )])
607        .expect("overlay applies");
608        assert!(validator.metadata_catalog.get().is_none(), "overlay construction must leave rule metadata lazy");
609
610        let base = shared_base_schema_metadata().expect("base metadata parses");
611        let merged = validator.schema_metadata_catalog().expect("overlay metadata merges");
612        let merged_again = validator.schema_metadata_catalog().expect("merged metadata remains available");
613
614        assert!(!Arc::ptr_eq(&base, &merged), "an overlay must not mutate the shared base catalog");
615        assert!(Arc::ptr_eq(&merged, &merged_again), "an overlay validator must cache one merged Arc");
616        assert!(
617            merged["AWS::S3::Bucket"].properties.contains(&"OverlayProperty".to_string()),
618            "the shared engine catalog must contain the overlay-derived property"
619        );
620        assert!(
621            !base["AWS::S3::Bucket"].properties.contains(&"OverlayProperty".to_string()),
622            "the process-wide base catalog must remain unchanged"
623        );
624    }
625
626    #[test]
627    fn default_getatt_table_is_shared_and_overlays_copy_it() {
628        let base = shared_base_getatt_data().expect("base GetAtt table parses");
629        let again = shared_base_getatt_data().expect("base GetAtt table parses");
630        assert!(Arc::ptr_eq(&base, &again), "default consumers must share the process-wide Arc");
631        assert!(
632            Arc::ptr_eq(&base, &getatt_data_with_overlays(&OverlayCatalog::default()).expect("no overlay")),
633            "an empty overlay must hand back the shared base rather than a copy"
634        );
635
636        let validator = SchemaValidator::try_with_additional_schemas([(
637            "AWS::S3::Bucket",
638            serde_json::json!({
639                "properties": {"OverlayAttribute": {"type": "integer"}},
640                "readOnlyProperties": ["/properties/OverlayAttribute"]
641            }),
642        )])
643        .expect("overlay applies");
644        let merged = getatt_data_with_overlays(validator.overlay_catalog()).expect("overlay GetAtt table merges");
645
646        assert!(!Arc::ptr_eq(&base, &merged), "an overlay must not mutate the shared base table");
647        assert_eq!(
648            merged.getatt_attribute_types["AWS::S3::Bucket"].get("OverlayAttribute").map(String::as_str),
649            Some("integer"),
650            "the merged table must carry the overlay-derived attribute type"
651        );
652        assert!(
653            merged.getatt_attributes["AWS::S3::Bucket"].contains(&"OverlayAttribute".to_string()),
654            "the merged table must list the overlay-derived attribute"
655        );
656        assert!(
657            merged.getatt_attribute_types["AWS::S3::Bucket"].contains_key("Arn"),
658            "bundled attributes of an overlaid type must survive the merge"
659        );
660        assert!(
661            !base.getatt_attribute_types["AWS::S3::Bucket"].contains_key("OverlayAttribute"),
662            "the process-wide base table must remain unchanged"
663        );
664        assert_eq!(
665            validator.store.ref_types().getatt_type_for("AWS::S3::Bucket", "OverlayAttribute"),
666            Some("integer"),
667            "the overlay validator's store sees the overlaid attribute type"
668        );
669        assert_eq!(
670            SchemaValidator::default().store.ref_types().getatt_type_for("AWS::S3::Bucket", "OverlayAttribute"),
671            None,
672            "a default validator built afterwards must still see the untouched shared table"
673        );
674    }
675
676    #[test]
677    fn should_warn_for_aws_types_but_not_private_providers() {
678        assert!(
679            should_warn_for_inserted_type("AWS::S3::NewBucket"),
680            "unknown AWS::* types should warn - likely typo or unpublished"
681        );
682        assert!(
683            should_warn_for_inserted_type("AWS::EC2::Instance"),
684            "AWS namespace insertion warns regardless of whether the type exists"
685        );
686        assert!(
687            !should_warn_for_inserted_type("MyOrg::Network::Firewall"),
688            "private/custom provider types are supported and should not warn"
689        );
690        assert!(
691            !should_warn_for_inserted_type("Acme::Storage::ObjectStore"),
692            "third-party provider types are supported and should not warn"
693        );
694    }
695}