Skip to main content

camel_component_validator/
compiled.rs

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