1pub mod catalog;
2pub(crate) mod compiled;
3pub(crate) mod convert;
4pub mod overlay;
5pub mod store;
6pub mod validate;
7
8#[cfg(feature = "uniffi-bindings")]
9uniffi::setup_scaffolding!();
10
11pub use catalog::OverlayCatalog;
12pub use data_source::{AdditionalSchemaSource, SchemaSourceError};
13pub use overlay::{MAX_OVERLAY_DEPTH, SchemaOverlayError};
14pub use store::{CompiledSchemaStore, OverlayOutcome};
15
16pub fn prewarm_embedded_data() {
19 data_source::embedded::warm_all();
20}
21
22use diagnostics::{Diagnostic, PhaseMetric, phase_metric};
23use log::{info, warn};
24use rules::{RuleInfo, lookup_rule};
25use serde::{Deserialize, Serialize};
26use std::sync::Arc;
27use template_model::SemanticModel;
28
29pub struct SchemaValidationResult {
30 pub diagnostics: Vec<Diagnostic>,
31 pub metric: PhaseMetric,
32}
33
34#[derive(Default, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
39#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
40#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
41#[serde(rename_all = "camelCase")]
42pub struct SchemaValidatorConfig {
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
47 pub additional_schemas: Vec<AdditionalSchemaSource>,
48}
49
50impl SchemaValidatorConfig {
51 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn with_additional_schemas(mut self, schemas: impl IntoIterator<Item = AdditionalSchemaSource>) -> Self {
58 self.additional_schemas.extend(schemas);
59 self
60 }
61}
62
63#[derive(Debug)]
65pub enum SchemaValidatorConfigError {
66 Source(SchemaSourceError),
68 Overlay(SchemaOverlayError),
70}
71
72impl std::fmt::Display for SchemaValidatorConfigError {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 SchemaValidatorConfigError::Source(e) => write!(f, "{e}"),
76 SchemaValidatorConfigError::Overlay(e) => {
77 write!(f, "Failed to apply an additional schema: {e}")
78 }
79 }
80 }
81}
82
83impl std::error::Error for SchemaValidatorConfigError {
84 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85 match self {
86 SchemaValidatorConfigError::Source(e) => Some(e),
87 SchemaValidatorConfigError::Overlay(e) => Some(e),
88 }
89 }
90}
91
92impl From<SchemaSourceError> for SchemaValidatorConfigError {
93 fn from(e: SchemaSourceError) -> Self {
94 SchemaValidatorConfigError::Source(e)
95 }
96}
97
98impl From<SchemaOverlayError> for SchemaValidatorConfigError {
99 fn from(e: SchemaOverlayError) -> Self {
100 SchemaValidatorConfigError::Overlay(e)
101 }
102}
103
104#[doc(hidden)]
111pub fn build_overlay_catalog<I, S>(overlays: I) -> Result<OverlayCatalog, SchemaOverlayError>
112where
113 I: IntoIterator<Item = (S, serde_json::Value)>,
114 S: AsRef<str>,
115{
116 let mut store = CompiledSchemaStore::new();
117 let mut type_names: Vec<String> = Vec::new();
118 for (type_name, schema) in overlays {
119 let type_name = type_name.as_ref();
120 store.apply_overlay(type_name, &schema)?;
121 if !type_names.contains(&type_name.to_string()) {
122 type_names.push(type_name.to_string());
123 }
124 }
125 build_validated_overlay_catalog(&store, &type_names)
126}
127
128fn build_validated_overlay_catalog(
132 store: &CompiledSchemaStore,
133 type_names: &[String],
134) -> Result<OverlayCatalog, SchemaOverlayError> {
135 for type_name in type_names {
136 if let Some(schema) = store.get(type_name)
137 && let Some((path, target)) = overlay::find_dangling_refs(schema).into_iter().next()
138 {
139 return Err(SchemaOverlayError::DanglingRef { type_name: type_name.clone(), path, target });
140 }
141 }
142 Ok(OverlayCatalog::from_store(store, type_names))
143}
144
145pub struct SchemaValidator {
146 store: CompiledSchemaStore,
147 catalog: OverlayCatalog,
148 init_metric: PhaseMetric,
149}
150
151impl Default for SchemaValidator {
152 fn default() -> Self {
153 Self::new_default()
154 }
155}
156
157impl SchemaValidator {
158 pub fn new(config: SchemaValidatorConfig) -> Result<Self, SchemaValidatorConfigError> {
171 if config.additional_schemas.is_empty() {
172 return Ok(Self::new_default());
173 }
174 let overlays: Vec<(String, serde_json::Value)> =
175 config.additional_schemas.iter().map(|s| s.resolve()).collect::<Result<Vec<_>, _>>()?;
176 Ok(Self::try_with_additional_schemas(overlays)?)
177 }
178
179 fn new_default() -> Self {
181 let start = web_time::Instant::now();
182 let store = CompiledSchemaStore::new();
183 Self::finish(store, OverlayCatalog::default(), 0, start)
184 }
185
186 pub fn try_with_additional_schemas<I, S>(additional_schemas: I) -> Result<Self, SchemaOverlayError>
203 where
204 I: IntoIterator<Item = (S, serde_json::Value)>,
205 S: AsRef<str>,
206 {
207 let start = web_time::Instant::now();
208 let mut store = CompiledSchemaStore::new();
209 let mut applied = 0usize;
210 let mut type_names: Vec<String> = Vec::new();
211 for (type_name, schema) in additional_schemas {
212 let type_name = type_name.as_ref();
213 if store.apply_overlay(type_name, &schema)? == OverlayOutcome::Inserted {
214 if should_warn_for_inserted_type(type_name) {
215 warn!(
216 "Additional schema for '{type_name}' matches no bundled resource type; registering it as a \
217 new type. Check the type name if this was meant to extend an existing type."
218 );
219 } else {
220 info!("Additional schema for '{type_name}' registered as a new type (private/custom provider).");
221 }
222 }
223 if !type_names.contains(&type_name.to_string()) {
224 type_names.push(type_name.to_string());
225 }
226 applied += 1;
227 }
228 let catalog = build_validated_overlay_catalog(&store, &type_names)?;
229 Ok(Self::finish(store, catalog, applied, start))
230 }
231
232 #[doc(hidden)]
236 pub fn overlay_catalog(&self) -> &OverlayCatalog {
237 &self.catalog
238 }
239
240 fn finish(
241 store: CompiledSchemaStore,
242 catalog: OverlayCatalog,
243 overlays_applied: usize,
244 start: web_time::Instant,
245 ) -> Self {
246 let init_metric = phase_metric(start);
247 if overlays_applied > 0 {
248 info!(
249 "SchemaValidator initialized: {} schemas loaded, {overlays_applied} overlay schema(s) applied",
250 store.len()
251 );
252 } else {
253 info!("SchemaValidator initialized: {} schemas loaded", store.len());
254 }
255 SchemaValidator { store, catalog, init_metric }
256 }
257
258 pub fn init_metric(&self) -> &PhaseMetric {
259 &self.init_metric
260 }
261
262 pub fn validate(&self, model: &Arc<SemanticModel>, region: Option<&str>) -> SchemaValidationResult {
267 let start = web_time::Instant::now();
268 let diagnostics = validate::validate_all_resources(&self.store, model, region);
269 let metric = phase_metric(start);
270 SchemaValidationResult { diagnostics, metric }
271 }
272
273 pub fn enrich_context(&self, diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
274 validate::enrich_schema_context(diagnostics, &self.store, model);
275 }
276
277 pub fn schema_count(&self) -> usize {
278 self.store.len()
279 }
280
281 pub fn list_rules(&self) -> Vec<RuleInfo> {
282 const SCHEMA_RULE_IDS: &[&str] = &[
284 "F3002", "F3003", "F3012", "F3014", "F3017", "F3018", "F3020", "F3021", "F3030", "W3030", "F3031", "F3032",
285 "F3033", "F3034", "F3037", "E3040", "W9054", "F3058", "E3030", "F3006", "E9006", "E2531", "E2533", "E3710",
286 "E1103", "W9003", "W2531", "W3696", "W3697", "W9009", "I9001", "I9002",
287 ];
288 SCHEMA_RULE_IDS.iter().filter_map(|id| lookup_rule(id).map(|r| r.to_rule_info())).collect()
289 }
290}
291
292pub(crate) fn should_warn_for_inserted_type(type_name: &str) -> bool {
298 type_name.starts_with("AWS::")
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn new_without_overlays_matches_default_construction() {
307 let config = SchemaValidatorConfig::default();
308 let validator = SchemaValidator::new(config).expect("an empty config builds");
309 assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
310 }
311
312 #[test]
313 fn new_applies_configured_overlays() {
314 let config = SchemaValidatorConfig {
315 additional_schemas: vec![AdditionalSchemaSource {
316 type_name: None,
317 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
318 .into(),
319 }],
320 };
321 let validator = SchemaValidator::new(config).expect("a valid overlay builds");
322 let template = br#"
323Resources:
324 Fn:
325 Type: AWS::Lambda::Function
326 Properties:
327 Code:
328 ZipFile: "exports.handler = async () => {};"
329 Role: arn:aws:iam::123456789012:role/lambda-role
330 Runtime: nodejs18.x
331 Handler: index.handler
332 TestForOverride: enabled
333"#;
334 let model = Arc::new(SemanticModel::from_bytes(template).expect("template parses"));
335 let diagnostics = validator.validate(&model, Some("us-east-1")).diagnostics;
336 assert!(
337 !diagnostics.iter().any(|d| d.rule_id == "F3002"),
338 "the configured overlay must reach schema validation, got: {:?}",
339 diagnostics.iter().map(|d| (&d.rule_id, &d.message)).collect::<Vec<_>>()
340 );
341 }
342
343 #[test]
344 fn new_reports_a_malformed_overlay() {
345 let config = SchemaValidatorConfig {
346 additional_schemas: vec![AdditionalSchemaSource {
347 type_name: Some("AWS::Test::Cycle".into()),
348 schema: r##"{"properties":{"P":{"$ref":"#/definitions/D"}},"definitions":{"D":{"$ref":"#/definitions/D"}}}"##
349 .into(),
350 }],
351 };
352 let message = match SchemaValidator::new(config) {
353 Err(e) => e.to_string(),
354 Ok(_) => panic!("a cyclic overlay must fail construction"),
355 };
356 assert!(message.contains("cycle"), "the error must describe the cycle, got: {message}");
357 }
358
359 #[test]
360 fn config_builds_validator_with_overlays() {
361 let config = SchemaValidatorConfig {
362 additional_schemas: vec![AdditionalSchemaSource {
363 type_name: None,
364 schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
365 .into(),
366 }],
367 };
368 let validator = SchemaValidator::new(config).expect("valid config builds");
369 assert!(
370 !validator.overlay_catalog().is_empty(),
371 "validator built from SchemaValidatorConfig must expose a populated catalog"
372 );
373 assert!(
374 validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()),
375 "catalog must include the overlaid type"
376 );
377 }
378
379 #[test]
380 fn config_empty_builds_default_validator() {
381 let config = SchemaValidatorConfig::default();
382 let validator = SchemaValidator::new(config).expect("empty config builds");
383 assert_eq!(validator.schema_count(), SchemaValidator::default().schema_count());
384 assert!(validator.overlay_catalog().is_empty());
385 }
386
387 #[test]
388 fn should_warn_for_aws_types_but_not_private_providers() {
389 assert!(
390 should_warn_for_inserted_type("AWS::S3::NewBucket"),
391 "unknown AWS::* types should warn - likely typo or unpublished"
392 );
393 assert!(
394 should_warn_for_inserted_type("AWS::EC2::Instance"),
395 "AWS namespace insertion warns regardless of whether the type exists"
396 );
397 assert!(
398 !should_warn_for_inserted_type("MyOrg::Network::Firewall"),
399 "private/custom provider types are supported and should not warn"
400 );
401 assert!(
402 !should_warn_for_inserted_type("Acme::Storage::ObjectStore"),
403 "third-party provider types are supported and should not warn"
404 );
405 }
406}