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::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
219pub struct SchemaValidator {
220 store: CompiledSchemaStore,
221 catalog: OverlayCatalog,
222 init_metric: PhaseMetric,
223 metadata_catalog: OnceLock<Arc<SchemaMetadataCatalog>>,
228}
229
230impl Default for SchemaValidator {
231 fn default() -> Self {
232 Self::new_default()
233 }
234}
235
236impl SchemaValidator {
237 pub fn new(config: SchemaValidatorConfig) -> Result<Self, SchemaValidatorConfigError> {
250 if config.additional_schemas.is_empty() {
251 return Ok(Self::new_default());
252 }
253 let overlays: Vec<(String, serde_json::Value)> =
254 config.additional_schemas.iter().map(|s| s.resolve()).collect::<Result<Vec<_>, _>>()?;
255 Ok(Self::try_with_additional_schemas(overlays)?)
256 }
257
258 fn new_default() -> Self {
260 let start = web_time::Instant::now();
261 let store = CompiledSchemaStore::new();
262 Self::finish(store, OverlayCatalog::default(), 0, start)
263 }
264
265 pub fn try_with_additional_schemas<I, S>(additional_schemas: I) -> Result<Self, SchemaOverlayError>
282 where
283 I: IntoIterator<Item = (S, serde_json::Value)>,
284 S: AsRef<str>,
285 {
286 let start = web_time::Instant::now();
287 let mut store = CompiledSchemaStore::new();
288 let mut applied = 0usize;
289 let mut type_names: Vec<String> = Vec::new();
290 for (type_name, schema) in additional_schemas {
291 let type_name = type_name.as_ref();
292 if store.apply_overlay(type_name, &schema)? == OverlayOutcome::Inserted {
293 if should_warn_for_inserted_type(type_name) {
294 warn!(
295 "Additional schema for '{type_name}' matches no bundled resource type; registering it as a \
296 new type. Check the type name if this was meant to extend an existing type."
297 );
298 } else {
299 info!("Additional schema for '{type_name}' registered as a new type (private/custom provider).");
300 }
301 }
302 if !type_names.contains(&type_name.to_string()) {
303 type_names.push(type_name.to_string());
304 }
305 applied += 1;
306 }
307 let catalog = build_validated_overlay_catalog(&store, &type_names)?;
308 Ok(Self::finish(store, catalog, applied, start))
309 }
310
311 #[doc(hidden)]
315 pub fn overlay_catalog(&self) -> &OverlayCatalog {
316 &self.catalog
317 }
318
319 #[doc(hidden)]
332 pub fn schema_metadata_catalog(&self) -> Result<Arc<SchemaMetadataCatalog>, SchemaMetadataError> {
333 if let Some(existing) = self.metadata_catalog.get() {
334 return Ok(existing.clone());
335 }
336 let catalog = schema_metadata_catalog_with_overlays(&self.catalog)?;
337 Ok(self.metadata_catalog.get_or_init(|| catalog).clone())
338 }
339
340 fn finish(
341 store: CompiledSchemaStore,
342 catalog: OverlayCatalog,
343 overlays_applied: usize,
344 start: web_time::Instant,
345 ) -> Self {
346 let init_metric = phase_metric(start);
347 if overlays_applied > 0 {
348 info!(
349 "SchemaValidator initialized: {} schemas loaded, {overlays_applied} overlay schema(s) applied",
350 store.len()
351 );
352 } else {
353 info!("SchemaValidator initialized: {} schemas loaded", store.len());
354 }
355 SchemaValidator { store, catalog, init_metric, metadata_catalog: OnceLock::new() }
356 }
357
358 pub fn init_metric(&self) -> &PhaseMetric {
359 &self.init_metric
360 }
361
362 pub fn validate(&self, model: &Arc<SemanticModel>, region: Option<&str>) -> SchemaValidationResult {
367 let start = web_time::Instant::now();
368 let diagnostics = validate::validate_all_resources(&self.store, model, region);
369 let metric = phase_metric(start);
370 SchemaValidationResult { diagnostics, metric }
371 }
372
373 pub fn enrich_context(&self, diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
374 validate::enrich_schema_context(diagnostics, &self.store, model);
375 }
376
377 pub fn schema_count(&self) -> usize {
378 self.store.len()
379 }
380
381 pub fn resource_schema_metadata(&self, type_name: &str) -> Option<ResourceSchemaMetadata> {
384 self.store.get(type_name).map(ResourceSchemaMetadata::from_compiled)
385 }
386
387 pub fn has_resource_type(&self, type_name: &str) -> bool {
390 self.store.get(type_name).is_some()
391 }
392
393 pub fn resource_type_names(&self) -> impl Iterator<Item = &str> {
395 self.store.type_names()
396 }
397
398 pub fn list_rules(&self) -> Vec<RuleInfo> {
399 const SCHEMA_RULE_IDS: &[&str] = &[
401 "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "W3030", "F3031", "F3032",
402 "F3033", "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "F3006", "E9006", "E2531", "E2533", "E3710",
403 "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002",
404 ];
405 SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect()
406 }
407}
408
409pub(crate) fn should_warn_for_inserted_type(type_name: &str) -> bool {
415 type_name.starts_with("AWS::")
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 #[test]
423 fn new_without_overlays_matches_default_construction() {
424 let config = SchemaValidatorConfig::default();
425 let validator = SchemaValidator::new(config).expect("an empty config builds");
426 assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
427 }
428
429 #[test]
430 fn new_applies_configured_overlays() {
431 let config = SchemaValidatorConfig {
432 additional_schemas: vec![AdditionalSchemaSource {
433 type_name: None,
434 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
435 .into(),
436 }],
437 };
438 let validator = SchemaValidator::new(config).expect("a valid overlay builds");
439 let template = br#"
440Resources:
441 Fn:
442 Type: AWS::Lambda::Function
443 Properties:
444 Code:
445 ZipFile: "exports.handler = async () => {};"
446 Role: arn:aws:iam::123456789012:role/lambda-role
447 Runtime: nodejs18.x
448 Handler: index.handler
449 TestForOverride: enabled
450"#;
451 let model = Arc::new(SemanticModel::from_bytes(template).expect("template parses"));
452 let diagnostics = validator.validate(&model, Some("us-east-1")).diagnostics;
453 assert!(
454 !diagnostics.iter().any(|d| d.rule_id == "F3002"),
455 "the configured overlay must reach schema validation, got: {:?}",
456 diagnostics.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
457 );
458 }
459
460 #[test]
461 fn new_reports_a_malformed_overlay() {
462 let config = SchemaValidatorConfig {
463 additional_schemas: vec![AdditionalSchemaSource {
464 type_name: Some("AWS::Test::Cycle".into()),
465 schema: r##"{"properties":{"P":{"$ref":"#/definitions/D"}},"definitions":{"D":{"$ref":"#/definitions/D"}}}"##
466 .into(),
467 }],
468 };
469 let message = match SchemaValidator::new(config) {
470 Err(e) => e.to_string(),
471 Ok(_) => panic!("a cyclic overlay must fail construction"),
472 };
473 assert!(message.contains("cycle"), "the error must describe the cycle, got: {message}");
474 }
475
476 #[test]
477 fn config_builds_validator_with_overlays() {
478 let config = SchemaValidatorConfig {
479 additional_schemas: vec![AdditionalSchemaSource {
480 type_name: None,
481 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
482 .into(),
483 }],
484 };
485 let validator = SchemaValidator::new(config).expect("valid config builds");
486 assert!(
487 !validator.overlay_catalog().is_empty(),
488 "validator built from SchemaValidatorConfig must expose a populated catalog"
489 );
490 assert!(
491 validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()),
492 "catalog must include the overlaid type"
493 );
494 }
495
496 #[test]
497 fn config_empty_builds_default_validator() {
498 let config = SchemaValidatorConfig::default();
499 let validator = SchemaValidator::new(config).expect("empty config builds");
500 assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
501 assert!(validator.overlay_catalog().is_empty());
502 }
503
504 #[test]
505 fn schema_metadata_is_lazy_and_shared_by_default_validators() {
506 let first = SchemaValidator::default();
507 let second = SchemaValidator::default();
508 assert!(first.metadata_catalog.get().is_none(), "construction must not parse rule metadata");
509 assert!(second.metadata_catalog.get().is_none(), "construction must not parse rule metadata");
510
511 let first_catalog = first.schema_metadata_catalog().expect("embedded metadata parses");
512 let first_again = first.schema_metadata_catalog().expect("cached metadata remains available");
513 let second_catalog = second.schema_metadata_catalog().expect("embedded metadata is shared");
514
515 assert!(Arc::ptr_eq(&first_catalog, &first_again), "one validator must reuse its cached Arc");
516 assert!(Arc::ptr_eq(&first_catalog, &second_catalog), "default validators must share the process-wide Arc");
517 }
518
519 #[test]
520 fn overlay_schema_metadata_is_merged_once_per_validator() {
521 let validator = SchemaValidator::try_with_additional_schemas([(
522 "AWS::S3::Bucket",
523 serde_json::json!({"properties": {"OverlayProperty": {"type": "string"}}}),
524 )])
525 .expect("overlay applies");
526 assert!(validator.metadata_catalog.get().is_none(), "overlay construction must leave rule metadata lazy");
527
528 let base = shared_base_schema_metadata().expect("base metadata parses");
529 let merged = validator.schema_metadata_catalog().expect("overlay metadata merges");
530 let merged_again = validator.schema_metadata_catalog().expect("merged metadata remains available");
531
532 assert!(!Arc::ptr_eq(&base, &merged), "an overlay must not mutate the shared base catalog");
533 assert!(Arc::ptr_eq(&merged, &merged_again), "an overlay validator must cache one merged Arc");
534 assert!(
535 merged["AWS::S3::Bucket"].properties.contains(&"OverlayProperty".to_string()),
536 "the shared engine catalog must contain the overlay-derived property"
537 );
538 assert!(
539 !base["AWS::S3::Bucket"].properties.contains(&"OverlayProperty".to_string()),
540 "the process-wide base catalog must remain unchanged"
541 );
542 }
543
544 #[test]
545 fn should_warn_for_aws_types_but_not_private_providers() {
546 assert!(
547 should_warn_for_inserted_type("AWS::S3::NewBucket"),
548 "unknown AWS::* types should warn - likely typo or unpublished"
549 );
550 assert!(
551 should_warn_for_inserted_type("AWS::EC2::Instance"),
552 "AWS namespace insertion warns regardless of whether the type exists"
553 );
554 assert!(
555 !should_warn_for_inserted_type("MyOrg::Network::Firewall"),
556 "private/custom provider types are supported and should not warn"
557 );
558 assert!(
559 !should_warn_for_inserted_type("Acme::Storage::ObjectStore"),
560 "third-party provider types are supported and should not warn"
561 );
562 }
563}