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
19pub 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#[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 #[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 pub fn new() -> Self {
58 Self::default()
59 }
60
61 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#[derive(Debug)]
70pub enum SchemaValidatorConfigError {
71 Source(SchemaSourceError),
73 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#[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
133fn 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#[derive(Debug)]
152pub enum SchemaMetadataError {
153 Parse(serde_json::Error),
155 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
177pub 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 Ok(BASE.get_or_init(|| Arc::new(document.schema_metadata)).clone())
197}
198
199#[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#[derive(Debug)]
222pub enum GetattDataError {
223 Parse(serde_json::Error),
225 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
247pub 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 Ok(BASE.get_or_init(|| Arc::new(data)).clone())
268}
269
270#[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 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 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 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 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 #[doc(hidden)]
394 pub fn overlay_catalog(&self) -> &OverlayCatalog {
395 &self.catalog
396 }
397
398 #[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 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 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 #[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 pub fn resource_schema_metadata(&self, type_name: &str) -> Option<ResourceSchemaMetadata> {
474 self.store.get(type_name).map(ResourceSchemaMetadata::from_compiled)
475 }
476
477 pub fn has_resource_type(&self, type_name: &str) -> bool {
480 self.store.get(type_name).is_some()
481 }
482
483 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 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
500pub(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}