Skip to main content

camel_component_validator/
config.rs

1use std::path::{Path, PathBuf};
2
3use camel_component_api::CamelError;
4use percent_encoding::percent_decode_str;
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum SchemaType {
8    Xml,
9    Json,
10    Yaml,
11    RelaxNg,
12    Schematron,
13}
14
15/// Maximum number of schemas retained in the XSD bridge cache before eviction.
16pub const DEFAULT_SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
17/// Default for `fail_on_null_body`.
18pub const DEFAULT_FAIL_ON_NULL_BODY: bool = true;
19/// Default for `fail_on_null_header`.
20pub const DEFAULT_FAIL_ON_NULL_HEADER: bool = true;
21
22#[derive(Debug, Clone)]
23pub struct ValidatorConfig {
24    pub schema_path: PathBuf,
25    pub schema_type: SchemaType,
26    /// Maximum allowed body size in bytes. Bodies exceeding this limit are
27    /// rejected before validation begins. `None` means no limit.
28    pub max_payload_bytes: Option<usize>,
29    /// Maximum number of entries in the XSD bridge schema cache. When the
30    /// cache exceeds this limit, all entries are evicted before inserting the
31    /// new one. Only relevant for XSD validation.
32    pub schema_cache_max_entries: usize,
33    /// When `true` (default), reject exchanges whose body is empty/null.
34    /// When `false`, empty bodies pass through without validation.
35    pub fail_on_null_body: bool,
36    /// When set, validate the value of this header instead of the body.
37    pub header_name: Option<String>,
38    /// When `true` (default) and `header_name` is set, reject exchanges where
39    /// the header is missing. When `false`, missing headers pass through.
40    pub fail_on_null_header: bool,
41}
42
43impl ValidatorConfig {
44    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
45        let without_scheme = uri.strip_prefix("validator:").ok_or_else(|| {
46            CamelError::InvalidUri(format!(
47                "invalid validator URI: must start with 'validator:' — got '{uri}'"
48            ))
49        })?;
50
51        let (path_str, query) = match without_scheme.find('?') {
52            Some(idx) => (&without_scheme[..idx], Some(&without_scheme[idx + 1..])),
53            None => (without_scheme, None),
54        };
55
56        if path_str.is_empty() {
57            return Err(CamelError::InvalidUri(
58                "validator URI must specify a schema path".to_string(),
59            ));
60        }
61
62        validate_percent_encoding(path_str)?;
63        let decoded_path = percent_decode_str(path_str)
64            .decode_utf8()
65            .map_err(|e| CamelError::InvalidUri(format!("invalid UTF-8 in path: {e}")))?;
66        let schema_path = PathBuf::from(decoded_path.as_ref());
67
68        // Audit 2026-08-31, F4-6: reject traversal components AFTER percent
69        // decoding (a raw `%2e%2e/` must not bypass the check). Defense-in-depth
70        // parity with camel-template; the schema path is operator config, but a
71        // `..` here only ever means a bad deploy.
72        if schema_path
73            .components()
74            .any(|c| matches!(c, std::path::Component::ParentDir))
75        {
76            return Err(CamelError::InvalidUri(
77                "schema path contains '..' traversal component".to_string(),
78            ));
79        }
80
81        let schema_type = if let Some(q) = query {
82            let type_val = q.split('&').find_map(|kv| kv.strip_prefix("type="));
83            match type_val {
84                Some("xml") | Some("xml-schema") | Some("xsd") => SchemaType::Xml,
85                Some("json") | Some("json-schema") => SchemaType::Json,
86                Some("yaml") | Some("yaml-schema") => SchemaType::Yaml,
87                Some("rng") | Some("relaxng") => SchemaType::RelaxNg,
88                Some("sch") | Some("schematron") => SchemaType::Schematron,
89                Some(other) => {
90                    return Err(CamelError::InvalidUri(format!(
91                        "unknown schema type '{other}'; expected xml, json, yaml, rng, or schematron"
92                    )));
93                }
94                None => detect_type_from_extension(&schema_path)?,
95            }
96        } else {
97            detect_type_from_extension(&schema_path)?
98        };
99
100        let mut max_payload_bytes: Option<usize> = None;
101        let mut schema_cache_max_entries: usize = DEFAULT_SCHEMA_CACHE_MAX_ENTRIES;
102        let mut fail_on_null_body: bool = DEFAULT_FAIL_ON_NULL_BODY;
103        let mut header_name: Option<String> = None;
104        let mut fail_on_null_header: bool = DEFAULT_FAIL_ON_NULL_HEADER;
105
106        if let Some(q) = query {
107            for kv in q.split('&') {
108                if let Some(val) = kv.strip_prefix("maxPayloadBytes=") {
109                    max_payload_bytes = Some(val.parse::<usize>().map_err(|e| {
110                        CamelError::InvalidUri(format!("invalid maxPayloadBytes '{val}': {e}"))
111                    })?);
112                } else if let Some(val) = kv.strip_prefix("maxPayloadBytes:") {
113                    max_payload_bytes = Some(val.parse::<usize>().map_err(|e| {
114                        CamelError::InvalidUri(format!("invalid maxPayloadBytes '{val}': {e}"))
115                    })?);
116                } else if let Some(val) = kv.strip_prefix("schemaCacheMaxEntries=") {
117                    schema_cache_max_entries = val.parse::<usize>().map_err(|e| {
118                        CamelError::InvalidUri(format!(
119                            "invalid schemaCacheMaxEntries '{val}': {e}"
120                        ))
121                    })?;
122                } else if let Some(val) = kv.strip_prefix("failOnNullBody=") {
123                    fail_on_null_body = val.parse::<bool>().map_err(|e| {
124                        CamelError::InvalidUri(format!("invalid failOnNullBody '{val}': {e}"))
125                    })?;
126                } else if let Some(val) = kv.strip_prefix("headerName=") {
127                    header_name = Some(val.to_string());
128                } else if let Some(val) = kv.strip_prefix("failOnNullHeader=") {
129                    fail_on_null_header = val.parse::<bool>().map_err(|e| {
130                        CamelError::InvalidUri(format!("invalid failOnNullHeader '{val}': {e}"))
131                    })?;
132                }
133            }
134        }
135
136        Ok(ValidatorConfig {
137            schema_path,
138            schema_type,
139            max_payload_bytes,
140            schema_cache_max_entries,
141            fail_on_null_body,
142            header_name,
143            fail_on_null_header,
144        })
145    }
146}
147
148fn detect_type_from_extension(path: &Path) -> Result<SchemaType, CamelError> {
149    match path.extension().and_then(|e| e.to_str()) {
150        Some("xsd") => Ok(SchemaType::Xml),
151        Some("json") => Ok(SchemaType::Json),
152        Some("yaml") | Some("yml") => Ok(SchemaType::Yaml),
153        Some("rng") | Some("rnc") => Ok(SchemaType::RelaxNg),
154        Some("sch") => Ok(SchemaType::Schematron),
155        ext => Err(CamelError::InvalidUri(format!(
156            "cannot infer schema type from extension {ext:?}; use ?type=xml|json|yaml|rng|schematron"
157        ))),
158    }
159}
160
161fn validate_percent_encoding(input: &str) -> Result<(), CamelError> {
162    let bytes = input.as_bytes();
163    let mut i = 0usize;
164    while i < bytes.len() {
165        if bytes[i] == b'%' {
166            if i + 2 >= bytes.len() {
167                return Err(CamelError::InvalidUri(format!(
168                    "invalid percent-encoding in path: '{input}'"
169                )));
170            }
171            let is_hex = |b: u8| b.is_ascii_hexdigit();
172            if !is_hex(bytes[i + 1]) || !is_hex(bytes[i + 2]) {
173                return Err(CamelError::InvalidUri(format!(
174                    "invalid percent-encoding in path: '{input}'"
175                )));
176            }
177            i += 3;
178            continue;
179        }
180        i += 1;
181    }
182    Ok(())
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn detects_xml_from_xsd_extension() {
191        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
192        assert_eq!(cfg.schema_path, PathBuf::from("schemas/order.xsd"));
193        assert_eq!(cfg.schema_type, SchemaType::Xml);
194    }
195
196    #[test]
197    fn detects_json_from_json_extension() {
198        let cfg = ValidatorConfig::from_uri("validator:schemas/order.json").unwrap();
199        assert_eq!(cfg.schema_type, SchemaType::Json);
200    }
201
202    #[test]
203    fn detects_yaml_from_yaml_extension() {
204        let cfg = ValidatorConfig::from_uri("validator:schemas/order.yaml").unwrap();
205        assert_eq!(cfg.schema_type, SchemaType::Yaml);
206    }
207
208    #[test]
209    fn detects_yaml_from_yml_extension() {
210        let cfg = ValidatorConfig::from_uri("validator:schemas/order.yml").unwrap();
211        assert_eq!(cfg.schema_type, SchemaType::Yaml);
212    }
213
214    #[test]
215    fn type_param_overrides_extension() {
216        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?type=json").unwrap();
217        assert_eq!(cfg.schema_type, SchemaType::Json);
218    }
219
220    #[test]
221    fn wrong_scheme_errors() {
222        assert!(ValidatorConfig::from_uri("timer:tick").is_err());
223    }
224
225    #[test]
226    fn empty_path_errors() {
227        assert!(ValidatorConfig::from_uri("validator:").is_err());
228    }
229
230    #[test]
231    fn unknown_type_param_errors() {
232        assert!(ValidatorConfig::from_uri("validator:schema.xsd?type=csv").is_err());
233    }
234
235    #[test]
236    fn no_extension_no_type_param_errors() {
237        assert!(ValidatorConfig::from_uri("validator:schema").is_err());
238    }
239
240    #[test]
241    fn percent_encoded_path_decoded() {
242        let cfg = ValidatorConfig::from_uri("validator:/path/to/my%20schema.xsd").unwrap();
243        assert!(
244            cfg.schema_path.to_str().unwrap().contains("my schema.xsd"),
245            "expected decoded path, got {:?}",
246            cfg.schema_path
247        );
248    }
249
250    #[test]
251    fn normal_path_unchanged() {
252        let cfg = ValidatorConfig::from_uri("validator:/path/to/schema.xsd").unwrap();
253        assert_eq!(cfg.schema_path, PathBuf::from("/path/to/schema.xsd"));
254    }
255
256    #[test]
257    fn percent_encoded_multiple_segments() {
258        let cfg = ValidatorConfig::from_uri("validator:/my%20dir/my%20file.xsd").unwrap();
259        assert!(
260            cfg.schema_path.to_str().unwrap().contains("my dir"),
261            "expected decoded 'my dir', got {:?}",
262            cfg.schema_path
263        );
264        assert!(
265            cfg.schema_path.to_str().unwrap().contains("my file.xsd"),
266            "expected decoded 'my file.xsd', got {:?}",
267            cfg.schema_path
268        );
269    }
270
271    #[test]
272    fn percent_encoded_with_query_params() {
273        let cfg =
274            ValidatorConfig::from_uri("validator:/path/to/my%20schema.xsd?type=json").unwrap();
275        assert!(
276            cfg.schema_path.to_str().unwrap().contains("my schema.xsd"),
277            "expected decoded path, got {:?}",
278            cfg.schema_path
279        );
280        assert_eq!(cfg.schema_type, SchemaType::Json);
281    }
282
283    #[test]
284    fn invalid_percent_encoding_errors() {
285        // %ZZ is not a valid percent-encoding sequence
286        let result = ValidatorConfig::from_uri("validator:/path/%ZZfile.xsd");
287        assert!(
288            matches!(result, Err(CamelError::InvalidUri(msg)) if msg.contains("percent-encoding"))
289        );
290    }
291
292    #[test]
293    fn test_fail_on_null_body_default_true() {
294        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
295        assert!(cfg.fail_on_null_body);
296    }
297
298    #[test]
299    fn test_fail_on_null_body_false_passes_empty() {
300        let cfg =
301            ValidatorConfig::from_uri("validator:schemas/order.xsd?failOnNullBody=false").unwrap();
302        assert!(!cfg.fail_on_null_body);
303    }
304
305    #[test]
306    fn test_header_name_validation_option_parsed() {
307        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?headerName=X-My-Header")
308            .unwrap();
309        assert_eq!(cfg.header_name.as_deref(), Some("X-My-Header"));
310    }
311
312    #[test]
313    fn test_header_name_defaults_to_none() {
314        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
315        assert!(cfg.header_name.is_none());
316    }
317
318    #[test]
319    fn test_fail_on_null_header_default_true() {
320        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?headerName=X-H").unwrap();
321        assert!(cfg.fail_on_null_header);
322    }
323
324    /// Audit 2026-08-31, F4-6: traversal rejected even percent-encoded.
325    #[test]
326    fn schema_path_rejects_traversal_raw_and_percent_encoded() {
327        assert!(ValidatorConfig::from_uri("validator:../etc/passwd.xsd").is_err());
328        assert!(ValidatorConfig::from_uri("validator:%2e%2e/etc/passwd.xsd").is_err());
329        assert!(ValidatorConfig::from_uri("validator:a/../../b.xsd").is_err());
330        assert!(ValidatorConfig::from_uri("validator:schemas/order.xsd").is_ok());
331    }
332
333    #[test]
334    fn test_fail_on_null_header_false_passes_missing_header() {
335        let cfg = ValidatorConfig::from_uri(
336            "validator:schemas/order.xsd?headerName=X-H&failOnNullHeader=false",
337        )
338        .unwrap();
339        assert!(!cfg.fail_on_null_header);
340    }
341}