camel_component_validator/
config.rs1use 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
18pub const DEFAULT_SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
20pub const DEFAULT_FAIL_ON_NULL_BODY: bool = true;
22pub 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 pub max_payload_bytes: Option<usize>,
32 pub schema_cache_max_entries: usize,
36 pub fail_on_null_body: bool,
39 pub header_name: Option<String>,
41 pub fail_on_null_header: bool,
44 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 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 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
197fn 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
209fn 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 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 #[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 #[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 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 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 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 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}