Skip to main content

camel_component_validator/
compiled.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use camel_component_api::{Body, CamelError};
6// serde_yml migrated to noyalib (compat-serde-yaml shim) — closes RUSTSEC-2025-0068.
7// Module alias preserves call-site paths byte-for-byte.
8use noyalib::compat::serde_yaml as serde_yml;
9use serde_yml::Value as YamlValue;
10
11use crate::config::{SchemaType, ValidatorConfig};
12use crate::error::ValidatorError;
13use crate::resolver::{FilesystemResolver, ResourceResolver};
14use crate::schema_set::{SchemaSet, discover_schema_set};
15use crate::xsd_bridge::XsdBridge;
16
17/// Returns an approximate byte-length for a [`Body`] variant.
18///
19/// This is a rough estimate used only for backpressure (rejecting obviously
20/// oversized payloads). `Body::Stream` is not measurable and returns `None`.
21pub(crate) fn approx_body_byte_len(body: &Body) -> Option<usize> {
22    match body {
23        Body::Empty => Some(0),
24        Body::Bytes(b) => Some(b.len()),
25        Body::Text(s) => Some(s.len()),
26        Body::Xml(s) => Some(s.len()),
27        Body::Json(v) => serde_json::to_vec(v).ok().map(|v| v.len()),
28        // Stream and future variants are not measurably-sized.
29        _ => None,
30    }
31}
32
33pub(crate) enum CompiledValidator {
34    Xml {
35        /// Discovered schema set (root bytes, transitive imports/includes, and
36        /// the supplied alias map). `register` is called lazily on first
37        /// validation so bridge startup happens in an async context (avoiding
38        /// runtime issues caused by block_on's temporary runtime).
39        schema_set: Arc<SchemaSet>,
40        backend: Arc<dyn XsdBridge>,
41        max_payload_bytes: Option<usize>,
42    },
43    Json {
44        validator: Arc<jsonschema::Validator>,
45        max_payload_bytes: Option<usize>,
46    },
47    Yaml {
48        validator: Arc<jsonschema::Validator>,
49        max_payload_bytes: Option<usize>,
50    },
51}
52
53impl std::fmt::Debug for CompiledValidator {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("CompiledValidator").finish_non_exhaustive()
56    }
57}
58
59impl CompiledValidator {
60    pub fn compile(
61        config: &ValidatorConfig,
62        xsd_backend: Arc<dyn XsdBridge>,
63    ) -> Result<Self, CamelError> {
64        // Defense-in-depth: `from_uri` caps aliases at parse time, but
65        // `ValidatorConfig` fields are public and a programmatic constructor
66        // can bypass it. Run before any schema-type dispatch or filesystem
67        // read so the bounded cap error wins over a generic missing-schema
68        // error.
69        config.validate_schema_aliases()?;
70        let path = &config.schema_path;
71
72        match config.schema_type {
73            SchemaType::Xml => Self::compile_xsd(
74                path,
75                &config.schema_aliases,
76                xsd_backend,
77                config.max_payload_bytes,
78            ),
79            SchemaType::Json | SchemaType::Yaml => {
80                let resolver = FilesystemResolver;
81                let path_str = path.to_str().ok_or_else(|| {
82                    CamelError::EndpointCreationFailed(
83                        "schema path contains non-UTF-8 characters".into(),
84                    )
85                })?;
86                let content = resolver.resolve(path_str)?;
87                match config.schema_type {
88                    SchemaType::Json => {
89                        Self::compile_json(&content, path, config.max_payload_bytes)
90                    }
91                    SchemaType::Yaml => {
92                        Self::compile_yaml_schema(&content, path, config.max_payload_bytes)
93                    }
94                    _ => unreachable!("outer arm restricts this branch to JSON and YAML"),
95                }
96            }
97            SchemaType::RelaxNg | SchemaType::Schematron => Err(CamelError::Config(format!(
98                "schema type {:?} is not yet supported; supported: Xml, Json, Yaml",
99                config.schema_type
100            ))),
101        }
102    }
103
104    /// Discovers the XSD schema set for `path` under `aliases` exactly once.
105    ///
106    /// Bridge startup and schema registration are deferred to the first
107    /// validate() call, which runs in a proper async context. Discovery is
108    /// fail-closed here so an unresolvable root or import surfaces at endpoint
109    /// creation as a domain error rather than a silent empty registration.
110    fn compile_xsd(
111        path: &Path,
112        aliases: &BTreeMap<String, String>,
113        backend: Arc<dyn XsdBridge>,
114        max_payload_bytes: Option<usize>,
115    ) -> Result<Self, CamelError> {
116        let set = discover_schema_set(path, aliases)
117            .map_err(|e| CamelError::EndpointCreationFailed(e.to_string()))?;
118        Ok(CompiledValidator::Xml {
119            schema_set: Arc::new(set),
120            backend,
121            max_payload_bytes,
122        })
123    }
124
125    fn compile_json(
126        content: &[u8],
127        path: &Path,
128        max_payload_bytes: Option<usize>,
129    ) -> Result<Self, CamelError> {
130        let schema_value: serde_json::Value = serde_json::from_slice(content).map_err(|e| {
131            CamelError::EndpointCreationFailed(format!(
132                "invalid JSON schema '{}': {e}",
133                path.display()
134            ))
135        })?;
136
137        let validator = jsonschema::validator_for(&schema_value).map_err(|e| {
138            CamelError::EndpointCreationFailed(format!(
139                "failed to compile JSON schema '{}': {e}",
140                path.display()
141            ))
142        })?;
143
144        Ok(CompiledValidator::Json {
145            validator: Arc::new(validator),
146            max_payload_bytes,
147        })
148    }
149
150    fn compile_yaml_schema(
151        content: &[u8],
152        path: &Path,
153        max_payload_bytes: Option<usize>,
154    ) -> Result<Self, CamelError> {
155        let yaml_str = std::str::from_utf8(content).map_err(|e| {
156            CamelError::EndpointCreationFailed(format!(
157                "YAML schema '{}' is not valid UTF-8: {e}",
158                path.display()
159            ))
160        })?;
161
162        let yaml_value: YamlValue = serde_yml::from_str(yaml_str).map_err(|e| {
163            CamelError::EndpointCreationFailed(format!(
164                "invalid YAML schema '{}': {e}",
165                path.display()
166            ))
167        })?;
168
169        let schema_value: serde_json::Value = serde_json::to_value(&yaml_value).map_err(|e| {
170            CamelError::EndpointCreationFailed(format!(
171                "failed to convert YAML schema to JSON '{}': {e}",
172                path.display()
173            ))
174        })?;
175
176        let validator = jsonschema::validator_for(&schema_value).map_err(|e| {
177            CamelError::EndpointCreationFailed(format!(
178                "failed to compile YAML schema '{}': {e}",
179                path.display()
180            ))
181        })?;
182
183        Ok(CompiledValidator::Yaml {
184            validator: Arc::new(validator),
185            max_payload_bytes,
186        })
187    }
188
189    pub async fn validate(&self, body: &Body) -> Result<(), CamelError> {
190        if let Some(limit) = self.max_payload_bytes()
191            && let Some(actual) = approx_body_byte_len(body)
192            && actual > limit
193        {
194            return Err(ValidatorError::PayloadTooLarge { actual, limit }.to_processor_error());
195        }
196
197        match self {
198            CompiledValidator::Xml {
199                schema_set,
200                backend,
201                ..
202            } => Self::validate_xml(schema_set, backend, body).await,
203            CompiledValidator::Json { validator, .. } => Self::validate_json(validator, body),
204            CompiledValidator::Yaml { validator, .. } => Self::validate_yaml(validator, body),
205        }
206    }
207
208    fn max_payload_bytes(&self) -> Option<usize> {
209        match self {
210            CompiledValidator::Xml {
211                max_payload_bytes, ..
212            } => *max_payload_bytes,
213            CompiledValidator::Json {
214                max_payload_bytes, ..
215            } => *max_payload_bytes,
216            CompiledValidator::Yaml {
217                max_payload_bytes, ..
218            } => *max_payload_bytes,
219        }
220    }
221
222    async fn validate_xml(
223        schema_set: &SchemaSet,
224        backend: &Arc<dyn XsdBridge>,
225        body: &Body,
226    ) -> Result<(), CamelError> {
227        // Register lazily (idempotent: no-op if already registered).
228        let schema_id = backend
229            .register(&schema_set.cache_key(), schema_set)
230            .await
231            .map_err(|e| CamelError::Config(format!("XSD registration failed: {e}")))?;
232
233        let xml_bytes = match body {
234            Body::Xml(s) => s.as_bytes().to_vec(),
235            Body::Text(s) => s.as_bytes().to_vec(),
236            Body::Bytes(b) => b.to_vec(),
237            _ => {
238                return Err(CamelError::ProcessorError(
239                    "XSD validator requires Body::Xml, Body::Text, or Body::Bytes".to_string(),
240                ));
241            }
242        };
243
244        backend
245            .validate(&schema_id, xml_bytes)
246            .await
247            .map_err(|e| e.to_processor_error())
248    }
249
250    fn validate_json(validator: &jsonschema::Validator, body: &Body) -> Result<(), CamelError> {
251        let json_value = match body {
252            Body::Json(v) => v.clone(),
253            Body::Text(s) => serde_json::from_str(s)
254                .map_err(|e| CamelError::ProcessorError(format!("body is not valid JSON: {e}")))?,
255            Body::Bytes(b) => serde_json::from_slice(b).map_err(|e| {
256                CamelError::ProcessorError(format!("body bytes are not valid JSON: {e}"))
257            })?,
258            _ => {
259                return Err(CamelError::ProcessorError(
260                    "JSON Schema validator requires Body::Json, Body::Text, or Body::Bytes"
261                        .to_string(),
262                ));
263            }
264        };
265
266        let messages: Vec<String> = validator
267            .iter_errors(&json_value)
268            .map(|e| format!("{e} at {}", e.instance_path()))
269            .collect();
270
271        if messages.is_empty() {
272            Ok(())
273        } else {
274            Err(CamelError::ProcessorError(format!(
275                "JSON Schema validation failed:\n{}",
276                messages.join("\n")
277            )))
278        }
279    }
280
281    fn validate_yaml(validator: &jsonschema::Validator, body: &Body) -> Result<(), CamelError> {
282        let yaml_str = match body {
283            Body::Text(s) => s.as_str(),
284            _ => {
285                return Err(CamelError::ProcessorError(
286                    "YAML validator requires a text body (Body::Text)".to_string(),
287                ));
288            }
289        };
290
291        let yaml_value: serde_yml::Value = serde_yml::from_str(yaml_str)
292            .map_err(|e| CamelError::ProcessorError(format!("body is not valid YAML: {e}")))?;
293
294        let json_value: serde_json::Value = serde_json::to_value(&yaml_value)
295            .map_err(|e| CamelError::ProcessorError(format!("YAML→JSON conversion failed: {e}")))?;
296
297        let messages: Vec<String> = validator
298            .iter_errors(&json_value)
299            .map(|e| format!("{e} at {}", e.instance_path()))
300            .collect();
301
302        if messages.is_empty() {
303            Ok(())
304        } else {
305            Err(CamelError::ProcessorError(format!(
306                "YAML Schema validation failed:\n{}",
307                messages.join("\n")
308            )))
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::config::{DEFAULT_SCHEMA_CACHE_MAX_ENTRIES, SchemaType, ValidatorConfig};
317    use async_trait::async_trait;
318
319    #[derive(Debug, Clone)]
320    struct MockBridge {
321        register_err: Option<ValidatorError>,
322    }
323
324    #[async_trait]
325    impl XsdBridge for MockBridge {
326        async fn register(
327            &self,
328            _schema_id: &str,
329            _set: &crate::schema_set::SchemaSet,
330        ) -> Result<String, ValidatorError> {
331            if let Some(err) = &self.register_err {
332                return Err(err.clone());
333            }
334            Ok("xsd-mock".to_string())
335        }
336
337        async fn validate(
338            &self,
339            _schema_id: &str,
340            _doc_bytes: Vec<u8>,
341        ) -> Result<(), ValidatorError> {
342            Ok(())
343        }
344    }
345
346    #[tokio::test]
347    async fn xsd_bridge_register_error_propagates_on_validate() {
348        let mut schema = tempfile::Builder::new().suffix(".xsd").tempfile().unwrap();
349        use std::io::Write;
350        schema.write_all(b"<xs:schema/>").unwrap();
351
352        let cfg = ValidatorConfig {
353            schema_path: schema.path().to_path_buf(),
354            schema_type: SchemaType::Xml,
355            max_payload_bytes: None,
356            schema_cache_max_entries: DEFAULT_SCHEMA_CACHE_MAX_ENTRIES,
357            fail_on_null_body: true,
358            header_name: None,
359            fail_on_null_header: true,
360            schema_aliases: BTreeMap::new(),
361        };
362
363        let bridge = Arc::new(MockBridge {
364            register_err: Some(ValidatorError::CompilationFailed {
365                message: "COMPILATION_FAILED".to_string(),
366                source: None,
367            }),
368        });
369
370        // compile() is now sync and always succeeds for XSD (deferred registration)
371        let compiled = CompiledValidator::compile(&cfg, bridge).expect("compile should succeed");
372
373        // The error surfaces on the first validate() call when register() is attempted
374        let err = compiled
375            .validate(&Body::Xml("<order/>".to_string()))
376            .await
377            .expect_err("expected validate to fail due to registration error");
378        assert!(matches!(err, CamelError::Config(_)));
379        assert!(err.to_string().contains("COMPILATION_FAILED"));
380    }
381
382    #[test]
383    fn approx_body_byte_len_variants() {
384        assert_eq!(approx_body_byte_len(&Body::Empty), Some(0));
385        assert_eq!(
386            approx_body_byte_len(&Body::Text("hello".to_string())),
387            Some(5)
388        );
389        assert_eq!(
390            approx_body_byte_len(&Body::Xml("<a/>".to_string())),
391            Some(4)
392        );
393        // Json is serialized to measure
394        let json_len = approx_body_byte_len(&Body::Json(serde_json::json!({"id": 1})));
395        assert!(json_len.is_some());
396        assert!(json_len.unwrap() > 0);
397    }
398}