Skip to main content

camel_component_validator/
config.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use camel_component_api::CamelError;
5use percent_encoding::percent_decode_str;
6
7use crate::schema_set::MAX_SCHEMA_ALIASES;
8
9#[derive(Debug, Clone, PartialEq)]
10pub enum SchemaType {
11    Xml,
12    Json,
13    Yaml,
14    RelaxNg,
15    Schematron,
16}
17
18/// Maximum number of schemas retained in the XSD bridge cache before eviction.
19pub const DEFAULT_SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
20/// Default for `fail_on_null_body`.
21pub const DEFAULT_FAIL_ON_NULL_BODY: bool = true;
22/// Default for `fail_on_null_header`.
23pub const DEFAULT_FAIL_ON_NULL_HEADER: bool = true;
24
25#[derive(Debug, Clone)]
26pub struct ValidatorConfig {
27    pub schema_path: PathBuf,
28    pub schema_type: SchemaType,
29    /// Maximum allowed body size in bytes. Bodies exceeding this limit are
30    /// rejected before validation begins. `None` means no limit.
31    pub max_payload_bytes: Option<usize>,
32    /// Maximum number of entries in the XSD bridge schema cache. When the
33    /// cache exceeds this limit, all entries are evicted before inserting the
34    /// new one. Only relevant for XSD validation.
35    pub schema_cache_max_entries: usize,
36    /// When `true` (default), reject exchanges whose body is empty/null.
37    /// When `false`, empty bodies pass through without validation.
38    pub fail_on_null_body: bool,
39    /// When set, validate the value of this header instead of the body.
40    pub header_name: Option<String>,
41    /// When `true` (default) and `header_name` is set, reject exchanges where
42    /// the header is missing. When `false`, missing headers pass through.
43    pub fail_on_null_header: bool,
44    /// Operator-supplied absolute-URI -> root-relative virtual path aliases,
45    /// parsed from the single `schemaAliases` query parameter (a
46    /// percent-encoded JSON object of string keys to string values). Empty
47    /// when the parameter is absent.
48    pub schema_aliases: BTreeMap<String, String>,
49}
50
51impl ValidatorConfig {
52    pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
53        let without_scheme = uri.strip_prefix("validator:").ok_or_else(|| {
54            CamelError::InvalidUri(format!(
55                "invalid validator URI: must start with 'validator:' — got '{uri}'"
56            ))
57        })?;
58
59        let (path_str, query) = match without_scheme.find('?') {
60            Some(idx) => (&without_scheme[..idx], Some(&without_scheme[idx + 1..])),
61            None => (without_scheme, None),
62        };
63
64        if path_str.is_empty() {
65            return Err(CamelError::InvalidUri(
66                "validator URI must specify a schema path".to_string(),
67            ));
68        }
69
70        validate_percent_encoding(path_str)?;
71        let decoded_path = percent_decode_str(path_str)
72            .decode_utf8()
73            .map_err(|e| CamelError::InvalidUri(format!("invalid UTF-8 in path: {e}")))?;
74        let schema_path = PathBuf::from(decoded_path.as_ref());
75
76        // Audit 2026-08-31, F4-6: reject traversal components AFTER percent
77        // decoding (a raw `%2e%2e/` must not bypass the check). Defense-in-depth
78        // parity with camel-template; the schema path is operator config, but a
79        // `..` here only ever means a bad deploy.
80        if schema_path
81            .components()
82            .any(|c| matches!(c, std::path::Component::ParentDir))
83        {
84            return Err(CamelError::InvalidUri(
85                "schema path contains '..' traversal component".to_string(),
86            ));
87        }
88
89        let schema_type = if let Some(q) = query {
90            let type_val = q.split('&').find_map(|kv| kv.strip_prefix("type="));
91            match type_val {
92                Some("xml") | Some("xml-schema") | Some("xsd") => SchemaType::Xml,
93                Some("json") | Some("json-schema") => SchemaType::Json,
94                Some("yaml") | Some("yaml-schema") => SchemaType::Yaml,
95                Some("rng") | Some("relaxng") => SchemaType::RelaxNg,
96                Some("sch") | Some("schematron") => SchemaType::Schematron,
97                Some(other) => {
98                    return Err(CamelError::InvalidUri(format!(
99                        "unknown schema type '{other}'; expected xml, json, yaml, rng, or schematron"
100                    )));
101                }
102                None => detect_type_from_extension(&schema_path)?,
103            }
104        } else {
105            detect_type_from_extension(&schema_path)?
106        };
107
108        let mut max_payload_bytes: Option<usize> = None;
109        let mut schema_cache_max_entries: usize = DEFAULT_SCHEMA_CACHE_MAX_ENTRIES;
110        let mut fail_on_null_body: bool = DEFAULT_FAIL_ON_NULL_BODY;
111        let mut header_name: Option<String> = None;
112        let mut fail_on_null_header: bool = DEFAULT_FAIL_ON_NULL_HEADER;
113        let mut schema_aliases: BTreeMap<String, String> = BTreeMap::new();
114        let mut schema_aliases_seen = false;
115
116        if let Some(q) = query {
117            for kv in q.split('&') {
118                if let Some(val) = kv.strip_prefix("maxPayloadBytes=") {
119                    max_payload_bytes = Some(val.parse::<usize>().map_err(|e| {
120                        CamelError::InvalidUri(format!("invalid maxPayloadBytes '{val}': {e}"))
121                    })?);
122                } else if let Some(val) = kv.strip_prefix("maxPayloadBytes:") {
123                    max_payload_bytes = Some(val.parse::<usize>().map_err(|e| {
124                        CamelError::InvalidUri(format!("invalid maxPayloadBytes '{val}': {e}"))
125                    })?);
126                } else if let Some(val) = kv.strip_prefix("schemaCacheMaxEntries=") {
127                    schema_cache_max_entries = val.parse::<usize>().map_err(|e| {
128                        CamelError::InvalidUri(format!(
129                            "invalid schemaCacheMaxEntries '{val}': {e}"
130                        ))
131                    })?;
132                } else if let Some(val) = kv.strip_prefix("failOnNullBody=") {
133                    fail_on_null_body = val.parse::<bool>().map_err(|e| {
134                        CamelError::InvalidUri(format!("invalid failOnNullBody '{val}': {e}"))
135                    })?;
136                } else if let Some(val) = kv.strip_prefix("headerName=") {
137                    header_name = Some(val.to_string());
138                } else if let Some(val) = kv.strip_prefix("failOnNullHeader=") {
139                    fail_on_null_header = val.parse::<bool>().map_err(|e| {
140                        CamelError::InvalidUri(format!("invalid failOnNullHeader '{val}': {e}"))
141                    })?;
142                } else if let Some(val) = kv.strip_prefix("schemaAliases=") {
143                    if schema_aliases_seen {
144                        return Err(CamelError::InvalidUri(
145                            "duplicate 'schemaAliases' query parameter".to_string(),
146                        ));
147                    }
148                    schema_aliases_seen = true;
149                    schema_aliases = parse_schema_aliases(val)?;
150                }
151            }
152        }
153
154        Ok(ValidatorConfig {
155            schema_path,
156            schema_type,
157            max_payload_bytes,
158            schema_cache_max_entries,
159            fail_on_null_body,
160            header_name,
161            fail_on_null_header,
162            schema_aliases,
163        })
164    }
165
166    /// Rejects a config whose public `schema_aliases` field was mutated past
167    /// the [`MAX_SCHEMA_ALIASES`] cap.
168    ///
169    /// [`ValidatorConfig::from_uri`] already enforces the cap at parse time
170    /// (via [`schema_alias_cap_violation`]); this is the config-level defense
171    /// for a programmatically constructed [`ValidatorConfig`], whose fields are
172    /// public and can be set to any length. It is called by
173    /// `CompiledValidator::compile` before any schema-type dispatch or
174    /// filesystem access, so an over-cap config fails with a bounded
175    /// cap-naming error rather than a generic missing-schema error.
176    pub fn validate_schema_aliases(&self) -> Result<(), CamelError> {
177        match schema_alias_cap_violation(self.schema_aliases.len()) {
178            Some(msg) => Err(CamelError::Config(msg)),
179            None => Ok(()),
180        }
181    }
182}
183
184fn detect_type_from_extension(path: &Path) -> Result<SchemaType, CamelError> {
185    match path.extension().and_then(|e| e.to_str()) {
186        Some("xsd") => Ok(SchemaType::Xml),
187        Some("json") => Ok(SchemaType::Json),
188        Some("yaml") | Some("yml") => Ok(SchemaType::Yaml),
189        Some("rng") | Some("rnc") => Ok(SchemaType::RelaxNg),
190        Some("sch") => Ok(SchemaType::Schematron),
191        ext => Err(CamelError::InvalidUri(format!(
192            "cannot infer schema type from extension {ext:?}; use ?type=xml|json|yaml|rng|schematron"
193        ))),
194    }
195}
196
197/// Bounded diagnostic for an over-cap alias map, or `None` when within cap.
198///
199/// Single source for the cap wording and the one [`MAX_SCHEMA_ALIASES`]
200/// constant, shared by the URI parse ([`CamelError::InvalidUri`]) and the
201/// config-level guard ([`ValidatorConfig::validate_schema_aliases`],
202/// [`CamelError::Config`]).
203fn schema_alias_cap_violation(len: usize) -> Option<String> {
204    (len > MAX_SCHEMA_ALIASES).then(|| {
205        format!("invalid 'schemaAliases': {len} entries exceed the {MAX_SCHEMA_ALIASES}-entry cap")
206    })
207}
208
209/// Parses the single `schemaAliases` query value: a percent-encoded JSON
210/// object of string keys to string values.
211///
212/// Encoding is validated STRICTLY before the value is decoded exactly once, so
213/// a malformed `%ZZ` fails closed with no partial decode. A double-encoded
214/// value therefore decodes to a still-encoded string and fails JSON parsing
215/// rather than being silently decoded twice. The shared strict scanner is
216/// reused; its path-specific diagnostic is replaced by a bounded, option-named
217/// error that never echoes the raw value.
218fn parse_schema_aliases(raw: &str) -> Result<BTreeMap<String, String>, CamelError> {
219    validate_percent_encoding(raw).map_err(|_| {
220        CamelError::InvalidUri("invalid percent-encoding in 'schemaAliases'".to_string())
221    })?;
222    let decoded = percent_decode_str(raw).decode_utf8().map_err(|_| {
223        CamelError::InvalidUri(
224            "invalid 'schemaAliases': value is not valid UTF-8 after percent-decoding".to_string(),
225        )
226    })?;
227    let aliases: BTreeMap<String, String> = serde_json::from_str(&decoded).map_err(|_| {
228        CamelError::InvalidUri(
229            "invalid 'schemaAliases': value must be a percent-encoded JSON object of \
230             string keys to string values"
231                .to_string(),
232        )
233    })?;
234    if let Some(msg) = schema_alias_cap_violation(aliases.len()) {
235        return Err(CamelError::InvalidUri(msg));
236    }
237    Ok(aliases)
238}
239
240fn validate_percent_encoding(input: &str) -> Result<(), CamelError> {
241    let bytes = input.as_bytes();
242    let mut i = 0usize;
243    while i < bytes.len() {
244        if bytes[i] == b'%' {
245            if i + 2 >= bytes.len() {
246                return Err(CamelError::InvalidUri(format!(
247                    "invalid percent-encoding in path: '{input}'"
248                )));
249            }
250            let is_hex = |b: u8| b.is_ascii_hexdigit();
251            if !is_hex(bytes[i + 1]) || !is_hex(bytes[i + 2]) {
252                return Err(CamelError::InvalidUri(format!(
253                    "invalid percent-encoding in path: '{input}'"
254                )));
255            }
256            i += 3;
257            continue;
258        }
259        i += 1;
260    }
261    Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn detects_xml_from_xsd_extension() {
270        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
271        assert_eq!(cfg.schema_path, PathBuf::from("schemas/order.xsd"));
272        assert_eq!(cfg.schema_type, SchemaType::Xml);
273    }
274
275    #[test]
276    fn detects_json_from_json_extension() {
277        let cfg = ValidatorConfig::from_uri("validator:schemas/order.json").unwrap();
278        assert_eq!(cfg.schema_type, SchemaType::Json);
279    }
280
281    #[test]
282    fn detects_yaml_from_yaml_extension() {
283        let cfg = ValidatorConfig::from_uri("validator:schemas/order.yaml").unwrap();
284        assert_eq!(cfg.schema_type, SchemaType::Yaml);
285    }
286
287    #[test]
288    fn detects_yaml_from_yml_extension() {
289        let cfg = ValidatorConfig::from_uri("validator:schemas/order.yml").unwrap();
290        assert_eq!(cfg.schema_type, SchemaType::Yaml);
291    }
292
293    #[test]
294    fn type_param_overrides_extension() {
295        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?type=json").unwrap();
296        assert_eq!(cfg.schema_type, SchemaType::Json);
297    }
298
299    #[test]
300    fn wrong_scheme_errors() {
301        assert!(ValidatorConfig::from_uri("timer:tick").is_err());
302    }
303
304    #[test]
305    fn empty_path_errors() {
306        assert!(ValidatorConfig::from_uri("validator:").is_err());
307    }
308
309    #[test]
310    fn unknown_type_param_errors() {
311        assert!(ValidatorConfig::from_uri("validator:schema.xsd?type=csv").is_err());
312    }
313
314    #[test]
315    fn no_extension_no_type_param_errors() {
316        assert!(ValidatorConfig::from_uri("validator:schema").is_err());
317    }
318
319    #[test]
320    fn percent_encoded_path_decoded() {
321        let cfg = ValidatorConfig::from_uri("validator:/path/to/my%20schema.xsd").unwrap();
322        assert!(
323            cfg.schema_path.to_str().unwrap().contains("my schema.xsd"),
324            "expected decoded path, got {:?}",
325            cfg.schema_path
326        );
327    }
328
329    #[test]
330    fn normal_path_unchanged() {
331        let cfg = ValidatorConfig::from_uri("validator:/path/to/schema.xsd").unwrap();
332        assert_eq!(cfg.schema_path, PathBuf::from("/path/to/schema.xsd"));
333    }
334
335    #[test]
336    fn percent_encoded_multiple_segments() {
337        let cfg = ValidatorConfig::from_uri("validator:/my%20dir/my%20file.xsd").unwrap();
338        assert!(
339            cfg.schema_path.to_str().unwrap().contains("my dir"),
340            "expected decoded 'my dir', got {:?}",
341            cfg.schema_path
342        );
343        assert!(
344            cfg.schema_path.to_str().unwrap().contains("my file.xsd"),
345            "expected decoded 'my file.xsd', got {:?}",
346            cfg.schema_path
347        );
348    }
349
350    #[test]
351    fn percent_encoded_with_query_params() {
352        let cfg =
353            ValidatorConfig::from_uri("validator:/path/to/my%20schema.xsd?type=json").unwrap();
354        assert!(
355            cfg.schema_path.to_str().unwrap().contains("my schema.xsd"),
356            "expected decoded path, got {:?}",
357            cfg.schema_path
358        );
359        assert_eq!(cfg.schema_type, SchemaType::Json);
360    }
361
362    #[test]
363    fn invalid_percent_encoding_errors() {
364        // %ZZ is not a valid percent-encoding sequence
365        let result = ValidatorConfig::from_uri("validator:/path/%ZZfile.xsd");
366        assert!(
367            matches!(result, Err(CamelError::InvalidUri(msg)) if msg.contains("percent-encoding"))
368        );
369    }
370
371    #[test]
372    fn test_fail_on_null_body_default_true() {
373        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
374        assert!(cfg.fail_on_null_body);
375    }
376
377    #[test]
378    fn test_fail_on_null_body_false_passes_empty() {
379        let cfg =
380            ValidatorConfig::from_uri("validator:schemas/order.xsd?failOnNullBody=false").unwrap();
381        assert!(!cfg.fail_on_null_body);
382    }
383
384    #[test]
385    fn test_header_name_validation_option_parsed() {
386        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?headerName=X-My-Header")
387            .unwrap();
388        assert_eq!(cfg.header_name.as_deref(), Some("X-My-Header"));
389    }
390
391    #[test]
392    fn test_header_name_defaults_to_none() {
393        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
394        assert!(cfg.header_name.is_none());
395    }
396
397    #[test]
398    fn test_fail_on_null_header_default_true() {
399        let cfg = ValidatorConfig::from_uri("validator:schemas/order.xsd?headerName=X-H").unwrap();
400        assert!(cfg.fail_on_null_header);
401    }
402
403    /// Audit 2026-08-31, F4-6: traversal rejected even percent-encoded.
404    #[test]
405    fn schema_path_rejects_traversal_raw_and_percent_encoded() {
406        assert!(ValidatorConfig::from_uri("validator:../etc/passwd.xsd").is_err());
407        assert!(ValidatorConfig::from_uri("validator:%2e%2e/etc/passwd.xsd").is_err());
408        assert!(ValidatorConfig::from_uri("validator:a/../../b.xsd").is_err());
409        assert!(ValidatorConfig::from_uri("validator:schemas/order.xsd").is_ok());
410    }
411
412    #[test]
413    fn test_fail_on_null_header_false_passes_missing_header() {
414        let cfg = ValidatorConfig::from_uri(
415            "validator:schemas/order.xsd?headerName=X-H&failOnNullHeader=false",
416        )
417        .unwrap();
418        assert!(!cfg.fail_on_null_header);
419    }
420
421    // ── schemaAliases query parameter ───────────────────────────────────
422
423    #[test]
424    fn schema_aliases_json_parsed() {
425        let uri = "validator:schemas/order.xsd?schemaAliases=%7B%22http%3A%2F%2Fexample.com%2Fparent.xsd%22%3A%22a%2Fparent.xsd%22%7D";
426        let cfg = ValidatorConfig::from_uri(uri).expect("percent-encoded JSON map must parse");
427        assert_eq!(
428            cfg.schema_aliases
429                .get("http://example.com/parent.xsd")
430                .map(String::as_str),
431            Some("a/parent.xsd"),
432            "decoded alias map must match the JSON object"
433        );
434        assert_eq!(cfg.schema_aliases.len(), 1);
435
436        // Absent parameter leaves the map empty and existing formats unchanged.
437        let plain = ValidatorConfig::from_uri("validator:schemas/order.xsd").unwrap();
438        assert!(plain.schema_aliases.is_empty());
439    }
440
441    #[test]
442    fn schema_aliases_duplicate_param_errors() {
443        let uri = "validator:schemas/order.xsd?schemaAliases=%7B%7D&schemaAliases=%7B%7D";
444        let result = ValidatorConfig::from_uri(uri);
445        assert!(
446            matches!(result, Err(CamelError::InvalidUri(_))),
447            "repeated schemaAliases query parameter must be rejected"
448        );
449    }
450
451    #[test]
452    fn schema_aliases_malformed_json_errors() {
453        // Table: not JSON at all, a JSON array, and a non-string member value.
454        for value in ["not-json", "%5B%5D", "%7B%22urn%3Aa%22%3A1%7D"] {
455            let uri = format!("validator:schemas/order.xsd?schemaAliases={value}");
456            let result = ValidatorConfig::from_uri(&uri);
457            assert!(
458                matches!(result, Err(CamelError::InvalidUri(_))),
459                "schemaAliases value '{value}' must be rejected"
460            );
461        }
462    }
463
464    #[test]
465    fn schema_aliases_malformed_percent_errors() {
466        let uri = "validator:schemas/order.xsd?schemaAliases=%7B%22urn%3Aa%22%3A%22%ZZ%22%7D";
467        let result = ValidatorConfig::from_uri(uri);
468        let Err(CamelError::InvalidUri(m)) = result else {
469            panic!("expected Err(InvalidUri) for malformed percent-encoding");
470        };
471        assert!(
472            m.contains("schemaAliases"),
473            "error must name the schemaAliases option, got: {m}"
474        );
475    }
476
477    #[test]
478    fn schema_aliases_percent_decoded_exactly_once() {
479        // Double-encoded: one decode yields the still-encoded `%7B...`, which
480        // is not JSON. The parser must decode exactly once, never twice.
481        let uri = "validator:schemas/order.xsd?schemaAliases=%257B%2522urn%253Aa%2522%253A%2522a%252Fparent.xsd%2522%257D";
482        let result = ValidatorConfig::from_uri(uri);
483        assert!(
484            matches!(result, Err(CamelError::InvalidUri(_))),
485            "a double-encoded schemaAliases value must fail after exactly one decode"
486        );
487    }
488
489    #[test]
490    fn schema_aliases_cap_enforced_at_parse() {
491        // 129 entries exceeds the 128-entry alias cap.
492        let mut obj = serde_json::Map::new();
493        for i in 0..=crate::schema_set::MAX_SCHEMA_ALIASES {
494            obj.insert(
495                format!("urn:a{i}"),
496                serde_json::Value::String("a/parent.xsd".to_string()),
497            );
498        }
499        let json = serde_json::Value::Object(obj).to_string();
500        let encoded =
501            percent_encoding::utf8_percent_encode(&json, percent_encoding::NON_ALPHANUMERIC)
502                .to_string();
503        let uri = format!("validator:schemas/order.xsd?schemaAliases={encoded}");
504        let result = ValidatorConfig::from_uri(&uri);
505        let Err(CamelError::InvalidUri(m)) = result else {
506            panic!("expected Err(InvalidUri) for an over-cap alias map");
507        };
508        assert!(
509            m.contains("128"),
510            "error must name the 128-entry cap, got: {m}"
511        );
512    }
513}