Skip to main content

data_source/
additional_schema_source.rs

1//! A pre-read CloudFormation resource provider schema to overlay on bundled
2//! schemas. This module provides the shared type used by both `EngineConfig`
3//! (in `validation-engine`) and `SchemaValidatorConfig` (in
4//! `schema-validator`) so that overlays are specified once and applied
5//! identically everywhere.
6//!
7//! The struct contains only pre-read data and pure resolution logic - no
8//! filesystem access. Host layers (CLI, language bindings) read files and
9//! populate the struct before passing it in. Feature-gated derives expose the
10//! same record shape through WASM and UniFFI bindings. `type_name` is optional:
11//! leaving it unset (`None`) requests the schema's own `typeName`, so the field
12//! maps to a naturally nullable, omittable value in every binding.
13
14use crate::compiled_schema::keywords;
15use serde::{Deserialize, Serialize};
16
17/// Error type for schema source resolution failures. Deliberately cheap to
18/// construct (just a message string) and convertible from the caller's own
19/// error type via `From<String>`.
20#[derive(Debug, Clone)]
21pub struct SchemaSourceError(pub String);
22
23impl std::fmt::Display for SchemaSourceError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.write_str(&self.0)
26    }
27}
28
29impl std::error::Error for SchemaSourceError {}
30
31impl From<String> for SchemaSourceError {
32    fn from(s: String) -> Self {
33        Self(s)
34    }
35}
36
37/// A single additional CloudFormation resource provider schema to overlay on top
38/// of the bundled schemas.
39///
40/// `type_name` identifies the resource type (e.g., `"AWS::Lambda::Function"`).
41/// When absent (`None`), the `typeName` field inside the schema JSON is used
42/// instead. When both are present they must agree.
43///
44/// `schema` is the complete resource provider schema as a JSON string, in the
45/// standard CloudFormation registry format.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
48#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
49#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
50#[serde(rename_all = "camelCase")]
51pub struct AdditionalSchemaSource {
52    /// The resource type name (e.g., `"AWS::Lambda::Function"`). When absent, the
53    /// `typeName` field of the schema JSON is used instead. When both are
54    /// present they must agree.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
57    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
58    pub type_name: Option<String>,
59    /// The complete resource provider schema as a JSON string, in the standard
60    /// CloudFormation registry format.
61    pub schema: String,
62}
63
64impl AdditionalSchemaSource {
65    /// Parse the schema JSON and resolve the effective resource type name into the
66    /// `(type_name, schema)` pair the schema validator consumes.
67    ///
68    /// Returns an error when the schema is not valid JSON, is not a JSON object,
69    /// provides no resource type name at all, or names one type explicitly and a
70    /// different one in the schema body - a contradiction that is far more likely
71    /// to be a copy/paste mistake than an intentional rename.
72    pub fn resolve(&self) -> Result<(String, serde_json::Value), SchemaSourceError> {
73        // An absent (`None`) or empty explicit name both mean "take the type name
74        // from the schema body"; collapse them to a single `&str` view so the
75        // resolution logic below is agnostic to which one the caller supplied.
76        let explicit = self.type_name.as_deref().unwrap_or("");
77        let label = if explicit.is_empty() { "<unnamed>" } else { explicit };
78        let schema: serde_json::Value = serde_json::from_str(&self.schema)
79            .map_err(|e| SchemaSourceError(format!("Invalid additional schema for '{label}': {e}")))?;
80        if !schema.is_object() {
81            return Err(SchemaSourceError(format!(
82                "Invalid additional schema for '{label}': expected a JSON object describing a CloudFormation \
83                 resource provider schema"
84            )));
85        }
86        if !explicit.is_empty() && explicit != explicit.trim() {
87            return Err(SchemaSourceError(format!(
88                "Invalid additional schema for '{label}': type name has leading or trailing whitespace"
89            )));
90        }
91        let in_schema = match schema.get(keywords::TYPE_NAME) {
92            Some(value) => {
93                let declared = value.as_str().ok_or_else(|| {
94                    SchemaSourceError(format!("Invalid additional schema for '{label}': 'typeName' must be a string"))
95                })?;
96                if declared != declared.trim() {
97                    return Err(SchemaSourceError(format!(
98                        "Invalid additional schema for '{label}': type name has leading or trailing whitespace"
99                    )));
100                }
101                (!declared.is_empty()).then_some(declared)
102            }
103            None => None,
104        };
105        let type_name = match (explicit, in_schema) {
106            ("", None) => {
107                return Err(SchemaSourceError(
108                    "Additional schema is missing a resource type name (no explicit type name and no typeName in \
109                     the schema)"
110                        .to_string(),
111                ));
112            }
113            ("", Some(from_schema)) => from_schema.to_string(),
114            (explicit, None) => explicit.to_string(),
115            (explicit, Some(from_schema)) if explicit == from_schema => explicit.to_string(),
116            (explicit, Some(from_schema)) => {
117                return Err(SchemaSourceError(format!(
118                    "Invalid additional schema for '{explicit}': the schema declares typeName '{from_schema}'; \
119                     remove one of them or make them match"
120                )));
121            }
122        };
123        // Reject leading/trailing whitespace in the resolved type name.
124        if type_name != type_name.trim() {
125            return Err(SchemaSourceError(format!(
126                "Invalid additional schema for '{label}': type name has leading or trailing whitespace"
127            )));
128        }
129        Ok((type_name, schema))
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn error_msg(result: Result<(String, serde_json::Value), SchemaSourceError>) -> String {
138        result.expect_err("expected error").0
139    }
140
141    #[test]
142    fn resolve_uses_explicit_type_name() {
143        let src = AdditionalSchemaSource {
144            type_name: Some("AWS::Lambda::Function".into()),
145            schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
146        };
147        let (type_name, schema) = src.resolve().expect("valid schema resolves");
148        assert_eq!(type_name, "AWS::Lambda::Function");
149        assert!(schema.is_object());
150    }
151
152    #[test]
153    fn resolve_accepts_matching_type_names() {
154        let src = AdditionalSchemaSource {
155            type_name: Some("AWS::Lambda::Function".into()),
156            schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"P":{"type":"string"}}}"#.into(),
157        };
158        let (type_name, _) = src.resolve().expect("agreeing type names resolve");
159        assert_eq!(type_name, "AWS::Lambda::Function");
160    }
161
162    #[test]
163    fn resolve_rejects_contradictory_type_names() {
164        let src = AdditionalSchemaSource {
165            type_name: Some("AWS::Lambda::Function".into()),
166            schema: r#"{"typeName":"AWS::Other::Type","properties":{}}"#.into(),
167        };
168        let message = error_msg(src.resolve());
169        assert!(message.contains("AWS::Lambda::Function") && message.contains("AWS::Other::Type"));
170    }
171
172    #[test]
173    fn resolve_falls_back_to_schema_type_name() {
174        let src = AdditionalSchemaSource {
175            type_name: None,
176            schema: r#"{"typeName":"AWS::Lambda::Function","properties":{}}"#.into(),
177        };
178        let (type_name, _) = src.resolve().expect("valid schema resolves");
179        assert_eq!(type_name, "AWS::Lambda::Function");
180    }
181
182    #[test]
183    fn resolve_rejects_invalid_json() {
184        let src =
185            AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{ not json ".into() };
186        let message = error_msg(src.resolve());
187        assert!(message.contains("Invalid additional schema"));
188    }
189
190    #[test]
191    fn resolve_rejects_json_beyond_the_default_parser_depth_limit() {
192        const SERDE_JSON_DEFAULT_DEPTH_LIMIT: usize = 128;
193        let nesting = SERDE_JSON_DEFAULT_DEPTH_LIMIT + 1;
194        let schema = format!("{}null{}", "[".repeat(nesting), "]".repeat(nesting));
195        let src = AdditionalSchemaSource { type_name: Some("AWS::Test::Deep".into()), schema };
196
197        let message = error_msg(src.resolve());
198
199        assert!(message.contains("recursion limit exceeded"), "unexpected error: {message}");
200    }
201
202    #[test]
203    fn resolve_rejects_non_object() {
204        let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "42".into() };
205        let message = error_msg(src.resolve());
206        assert!(message.contains("expected a JSON object"));
207    }
208
209    #[test]
210    fn resolve_rejects_missing_type_name() {
211        let src = AdditionalSchemaSource { type_name: None, schema: r#"{"properties":{}}"#.into() };
212        let message = error_msg(src.resolve());
213        assert!(message.contains("missing a resource type name"));
214    }
215
216    #[test]
217    fn resolve_error_names_an_unnamed_source() {
218        let src = AdditionalSchemaSource { type_name: None, schema: "{ not json ".into() };
219        let message = error_msg(src.resolve());
220        assert!(message.contains("<unnamed>"));
221    }
222
223    #[test]
224    fn serializes_without_the_type_name_key_when_absent() {
225        let src = AdditionalSchemaSource { type_name: None, schema: "{}".into() };
226        let json = serde_json::to_value(&src).expect("serializes");
227        assert_eq!(json, serde_json::json!({ "schema": "{}" }), "an absent type name is omitted from the wire form");
228    }
229
230    #[test]
231    fn serializes_the_type_name_key_under_its_camel_case_name_when_present() {
232        let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{}".into() };
233        let json = serde_json::to_value(&src).expect("serializes");
234        assert_eq!(json, serde_json::json!({ "typeName": "AWS::Lambda::Function", "schema": "{}" }));
235    }
236
237    #[test]
238    fn deserializes_a_missing_type_name_to_none() {
239        let src: AdditionalSchemaSource = serde_json::from_str(r#"{"schema":"{}"}"#).expect("deserializes");
240        assert_eq!(src.type_name, None, "a wire form without typeName yields an absent type name");
241        assert_eq!(src.schema, "{}");
242    }
243
244    #[test]
245    fn deserializes_a_present_type_name_from_its_camel_case_key() {
246        let src: AdditionalSchemaSource =
247            serde_json::from_str(r#"{"typeName":"AWS::Lambda::Function","schema":"{}"}"#).expect("deserializes");
248        assert_eq!(src.type_name.as_deref(), Some("AWS::Lambda::Function"));
249    }
250
251    #[test]
252    fn round_trips_through_json_in_both_states() {
253        for original in [
254            AdditionalSchemaSource { type_name: None, schema: r#"{"typeName":"AWS::S3::Bucket"}"#.into() },
255            AdditionalSchemaSource { type_name: Some("AWS::S3::Bucket".into()), schema: "{}".into() },
256        ] {
257            let json = serde_json::to_string(&original).expect("serializes");
258            let restored: AdditionalSchemaSource = serde_json::from_str(&json).expect("deserializes");
259            assert_eq!(restored.type_name, original.type_name);
260            assert_eq!(restored.schema, original.schema);
261        }
262    }
263}