camel_component_validator/
config.rs1use 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
15pub const DEFAULT_SCHEMA_CACHE_MAX_ENTRIES: usize = 256;
17pub const DEFAULT_FAIL_ON_NULL_BODY: bool = true;
19pub 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 pub max_payload_bytes: Option<usize>,
29 pub schema_cache_max_entries: usize,
33 pub fail_on_null_body: bool,
36 pub header_name: Option<String>,
38 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 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 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 #[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}