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
18pub 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#[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 #[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 pub fn new() -> Self {
57 Self::default()
58 }
59
60 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#[derive(Debug)]
69pub enum SchemaValidatorConfigError {
70 Source(SchemaSourceError),
72 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#[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
132fn 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#[derive(Debug)]
151pub enum SchemaMetadataError {
152 Parse(serde_json::Error),
154 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
176pub 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 Ok(BASE.get_or_init(|| Arc::new(document.schema_metadata)).clone())
196}
197
198#[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#[derive(Debug)]
221pub enum GetattDataError {
222 Parse(serde_json::Error),
224 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
246pub 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 Ok(BASE.get_or_init(|| Arc::new(data)).clone())
267}
268
269#[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 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 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 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 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 #[doc(hidden)]
393 pub fn overlay_catalog(&self) -> &OverlayCatalog {
394 &self.catalog
395 }
396
397 #[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 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 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 pub fn resource_schema_metadata(&self, type_name: &str) -> Option<ResourceSchemaMetadata> {
465 self.store.get(type_name).map(ResourceSchemaMetadata::from_compiled)
466 }
467
468 pub fn has_resource_type(&self, type_name: &str) -> bool {
471 self.store.get(type_name).is_some()
472 }
473
474 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 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
491pub(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}