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