1use std::collections::HashMap;
2
3use camel_api::CamelError;
4
5#[derive(Clone, PartialEq)]
9pub struct UriComponents {
10 pub scheme: String,
12 pub path: String,
14 pub params: HashMap<String, String>,
16}
17
18const SENSITIVE_KEYS: &[&str] = &[
19 "password",
20 "secret",
21 "token",
22 "credential",
23 "apikey",
24 "accesskey",
25 "privatekey",
26];
27
28fn is_sensitive_key(key: &str) -> bool {
29 SENSITIVE_KEYS.contains(&key.to_lowercase().as_str())
30}
31
32fn unwrap_raw(value: &str) -> &str {
33 if value.starts_with("RAW(") && value.ends_with(')') {
34 &value[4..value.len() - 1]
35 } else {
36 value
37 }
38}
39
40fn is_raw_value(value: &str) -> bool {
41 value.starts_with("RAW(") && value.ends_with(')')
42}
43
44fn percent_decode(s: &str) -> Result<String, CamelError> {
51 let bytes = s.as_bytes();
52 let mut result = Vec::with_capacity(bytes.len());
53 let mut i = 0;
54 while i < bytes.len() {
55 if bytes[i] == b'%' {
56 if i + 2 >= bytes.len() {
57 return Err(CamelError::InvalidUri(format!(
58 "incomplete percent-encoding at position {i} in '{s}'"
59 )));
60 }
61 let hi = char::from(bytes[i + 1]);
62 let lo = char::from(bytes[i + 2]);
63 let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).map_err(|_| {
64 CamelError::InvalidUri(format!("invalid percent-encoding '%{hi}{lo}' in '{s}'"))
65 })?;
66 result.push(byte);
67 i += 3;
68 } else {
69 result.push(bytes[i]);
70 i += 1;
71 }
72 }
73 String::from_utf8(result).map_err(|e| {
74 CamelError::InvalidUri(format!("percent-decoded bytes are not valid UTF-8: {e}"))
75 })
76}
77
78impl std::fmt::Debug for UriComponents {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 let mut redacted_params = std::collections::HashMap::new();
81 for (k, v) in &self.params {
82 if is_sensitive_key(k) {
83 redacted_params.insert(k.clone(), "***".to_string());
84 } else {
85 redacted_params.insert(k.clone(), v.clone());
86 }
87 }
88 f.debug_struct("UriComponents")
89 .field("scheme", &self.scheme)
90 .field("path", &self.path)
91 .field("params", &redacted_params)
92 .finish()
93 }
94}
95
96pub fn parse_uri(uri: &str) -> Result<UriComponents, CamelError> {
100 let (scheme, rest) = uri.split_once(':').ok_or_else(|| {
101 CamelError::InvalidUri(format!("missing scheme separator ':' in '{uri}'"))
102 })?;
103
104 if scheme.is_empty() {
105 return Err(CamelError::InvalidUri(format!("empty scheme in '{uri}'")));
106 }
107
108 if !scheme
110 .chars()
111 .all(|c| c.is_ascii_alphanumeric() || c == '-')
112 {
113 return Err(CamelError::InvalidUri(format!(
114 "invalid scheme '{scheme}': must contain only alphanumeric characters and hyphens"
115 )));
116 }
117
118 let (path, params) = match rest.split_once('?') {
125 Some((path, query)) => (path, parse_query(query)?),
126 None => (rest, HashMap::new()),
127 };
128
129 Ok(UriComponents {
130 scheme: scheme.to_string(),
131 path: percent_decode(path)?,
132 params,
133 })
134}
135
136fn parse_query(query: &str) -> Result<HashMap<String, String>, CamelError> {
137 let mut params = HashMap::new();
138
139 for pair in split_query_pairs(query)
140 .into_iter()
141 .filter(|s| !s.is_empty())
142 {
143 let Some((key, value)) = pair.split_once('=') else {
144 return Err(CamelError::InvalidUri(format!(
145 "query parameter '{}' has no value",
146 pair
147 )));
148 };
149
150 let decoded_key = percent_decode(key)?;
151
152 if params.contains_key(&decoded_key) {
153 return Err(CamelError::InvalidUri(format!(
154 "duplicate query parameter: {}",
155 decoded_key
156 )));
157 }
158
159 let parsed_value = if is_raw_value(value) {
160 if is_sensitive_key(&decoded_key) {
167 unwrap_raw(value).to_string()
168 } else {
169 value.to_string()
170 }
171 } else if is_sensitive_key(&decoded_key) {
172 value.to_string()
174 } else {
175 percent_decode(value)?
177 };
178
179 params.insert(decoded_key, parsed_value);
180 }
181
182 Ok(params)
183}
184
185fn split_query_pairs(query: &str) -> Vec<&str> {
186 let mut pairs = Vec::new();
187 let mut start = 0usize;
188 let mut i = 0usize;
189 let mut raw_depth = 0usize;
190
191 while i < query.len() {
192 let rest = &query[i..];
193
194 if rest.starts_with("RAW(") {
195 raw_depth += 1;
196 i += 4;
197 continue;
198 }
199
200 let ch = rest.as_bytes()[0] as char;
201 match ch {
202 ')' if raw_depth > 0 => raw_depth -= 1,
203 '&' if raw_depth == 0 => {
204 pairs.push(&query[start..i]);
205 i += 1;
206 start = i;
207 continue;
208 }
209 _ => {}
210 }
211
212 i += 1;
213 }
214
215 pairs.push(&query[start..]);
216 pairs
217}
218
219pub fn parse_bool_param(s: &str) -> Result<bool, String> {
224 match s.to_lowercase().as_str() {
225 "true" | "1" | "yes" => Ok(true),
226 "false" | "0" | "no" => Ok(false),
227 _ => Err(format!("invalid boolean value: '{}'", s)),
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn test_parse_simple_uri() {
237 let result = parse_uri("timer:tick").unwrap();
238 assert_eq!(result.scheme, "timer");
239 assert_eq!(result.path, "tick");
240 assert!(result.params.is_empty());
241 }
242
243 #[test]
244 fn test_parse_uri_with_params() {
245 let result = parse_uri("timer:tick?period=1000&delay=500").unwrap();
246 assert_eq!(result.scheme, "timer");
247 assert_eq!(result.path, "tick");
248 assert_eq!(result.params.get("period"), Some(&"1000".to_string()));
249 assert_eq!(result.params.get("delay"), Some(&"500".to_string()));
250 }
251
252 #[test]
253 fn test_parse_uri_with_single_param() {
254 let result = parse_uri("log:info?level=debug").unwrap();
255 assert_eq!(result.scheme, "log");
256 assert_eq!(result.path, "info");
257 assert_eq!(result.params.get("level"), Some(&"debug".to_string()));
258 }
259
260 #[test]
261 fn test_parse_uri_no_scheme() {
262 let result = parse_uri("noscheme");
263 assert!(result.is_err());
264 }
265
266 #[test]
267 fn test_parse_uri_empty_scheme() {
268 let result = parse_uri(":path");
269 assert!(result.is_err());
270 }
271
272 #[test]
273 fn test_parse_direct_uri() {
274 let result = parse_uri("direct:myRoute").unwrap();
275 assert_eq!(result.scheme, "direct");
276 assert_eq!(result.path, "myRoute");
277 assert!(result.params.is_empty());
278 }
279
280 #[test]
281 fn test_parse_mock_uri() {
282 let result = parse_uri("mock:result").unwrap();
283 assert_eq!(result.scheme, "mock");
284 assert_eq!(result.path, "result");
285 }
286
287 #[test]
288 fn test_parse_http_uri_simple() {
289 let result = parse_uri("http://localhost:8080/api/users").unwrap();
290 assert_eq!(result.scheme, "http");
291 assert_eq!(result.path, "//localhost:8080/api/users");
292 assert!(result.params.is_empty());
293 }
294
295 #[test]
296 fn test_parse_https_uri_with_camel_params() {
297 let result = parse_uri(
298 "https://api.example.com/v1/data?httpMethod=POST&throwExceptionOnFailure=false",
299 )
300 .unwrap();
301 assert_eq!(result.scheme, "https");
302 assert_eq!(result.path, "//api.example.com/v1/data");
303 assert_eq!(result.params.get("httpMethod"), Some(&"POST".to_string()));
304 assert_eq!(
305 result.params.get("throwExceptionOnFailure"),
306 Some(&"false".to_string())
307 );
308 }
309
310 #[test]
311 fn test_parse_http_uri_no_path() {
312 let result = parse_uri("http://localhost:8080").unwrap();
313 assert_eq!(result.scheme, "http");
314 assert_eq!(result.path, "//localhost:8080");
315 assert!(result.params.is_empty());
316 }
317
318 #[test]
319 fn test_parse_http_uri_with_port_and_query() {
320 let result = parse_uri("http://example.com:3000/api?connectTimeout=5000").unwrap();
321 assert_eq!(result.scheme, "http");
322 assert_eq!(result.path, "//example.com:3000/api");
323 assert_eq!(
324 result.params.get("connectTimeout"),
325 Some(&"5000".to_string())
326 );
327 }
328
329 #[test]
330 fn test_uri_components_debug_redacts_sensitive_params() {
331 let uri = parse_uri("timer:tick?password=secret&token=abc123&name=hello").unwrap();
332 let debug_output = format!("{:?}", uri);
333 assert!(
334 !debug_output.contains("secret"),
335 "Debug must not contain password value"
336 );
337 assert!(
338 !debug_output.contains("abc123"),
339 "Debug must not contain token value"
340 );
341 assert!(
342 debug_output.contains("hello"),
343 "Debug should contain non-sensitive param values"
344 );
345 assert!(
346 debug_output.contains("password"),
347 "Debug should show param key 'password'"
348 );
349 }
350
351 #[test]
352 fn test_uri_components_debug_redacts_case_insensitive() {
353 let uri = parse_uri("timer:tick?Password=secret&TOKEN=abc123").unwrap();
354 let debug_output = format!("{:?}", uri);
355 assert!(
356 !debug_output.contains("secret"),
357 "Debug must redact 'Password' (capitalized)"
358 );
359 assert!(
360 !debug_output.contains("abc123"),
361 "Debug must redact 'TOKEN' (uppercase)"
362 );
363 }
364
365 #[test]
366 fn test_parse_bool_param_true_variants() {
367 for val in &["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
368 assert_eq!(
369 parse_bool_param(val),
370 Ok(true),
371 "parse_bool_param('{}') should be Ok(true)",
372 val
373 );
374 }
375 }
376
377 #[test]
378 fn test_parse_bool_param_false_variants() {
379 for val in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
380 assert_eq!(
381 parse_bool_param(val),
382 Ok(false),
383 "parse_bool_param('{}') should be Ok(false)",
384 val
385 );
386 }
387 }
388
389 #[test]
390 fn test_parse_bool_param_invalid() {
391 for val in &["maybe", "yes ", " true", "2", "-1", ""] {
392 assert!(
393 parse_bool_param(val).is_err(),
394 "parse_bool_param('{}') should be Err",
395 val
396 );
397 }
398 }
399
400 #[test]
401 fn test_raw_token_extracts_value() {
402 assert_eq!(unwrap_raw("RAW(p@ss!)"), "p@ss!");
403 assert_eq!(unwrap_raw("RAW(user:pass@host)"), "user:pass@host");
404 }
405
406 #[test]
407 fn test_non_raw_value_unchanged() {
408 assert_eq!(unwrap_raw("plainvalue"), "plainvalue");
409 assert_eq!(unwrap_raw("RAW(unclosed"), "RAW(unclosed");
410 }
411
412 #[test]
413 fn test_uri_with_raw_password_parses_correctly() {
414 let result = parse_uri("redis://localhost?password=RAW(p@ss!)").unwrap();
415 assert_eq!(result.params.get("password"), Some(&"p@ss!".to_string()));
416 }
417
418 #[test]
419 fn test_uri_with_raw_password_containing_ampersand_parses_correctly() {
420 let result = parse_uri("redis://localhost?password=RAW(a&b)&db=0").unwrap();
421 assert_eq!(result.params.get("password"), Some(&"a&b".to_string()));
422 assert_eq!(result.params.get("db"), Some(&"0".to_string()));
423 }
424
425 #[test]
426 fn test_uri_with_non_sensitive_raw_value_is_unchanged() {
427 let result = parse_uri("timer:tick?name=RAW(p@ss!)").unwrap();
428 assert_eq!(result.params.get("name"), Some(&"RAW(p@ss!)".to_string()));
429 }
430
431 #[test]
432 fn test_parse_uri_duplicate_query_key_returns_error() {
433 let result = parse_uri("foo:bar?key=a&key=b");
434 assert!(result.is_err());
435 match result {
436 Err(CamelError::InvalidUri(msg)) => {
437 assert_eq!(msg, "duplicate query parameter: key");
438 }
439 _ => panic!("Expected InvalidUri for duplicate key"),
440 }
441 }
442
443 #[test]
444 fn test_parse_uri_bare_query_param_returns_error() {
445 let result = parse_uri("foo:bar?flag");
446 assert!(result.is_err());
447 match result {
448 Err(CamelError::InvalidUri(msg)) => {
449 assert_eq!(msg, "query parameter 'flag' has no value");
450 }
451 _ => panic!("Expected InvalidUri for bare query parameter"),
452 }
453 }
454
455 #[test]
456 fn test_parse_uri_duplicate_key_with_raw_ampersand_returns_error() {
457 let result = parse_uri("foo:bar?password=RAW(a&b)&password=RAW(c&d)");
458 assert!(result.is_err());
459 match result {
460 Err(CamelError::InvalidUri(msg)) => {
461 assert_eq!(msg, "duplicate query parameter: password");
462 }
463 _ => panic!("Expected InvalidUri for duplicate key with RAW value"),
464 }
465 }
466
467 #[test]
470 fn test_valid_scheme_alphanumeric() {
471 let result = parse_uri("timer:tick").unwrap();
472 assert_eq!(result.scheme, "timer");
473 }
474
475 #[test]
476 fn test_valid_scheme_with_hyphen() {
477 let result = parse_uri("my-component:path").unwrap();
478 assert_eq!(result.scheme, "my-component");
479 }
480
481 #[test]
482 fn test_valid_scheme_alphanumeric_only() {
483 let result = parse_uri("opensearchs://host:9200/idx").unwrap();
484 assert_eq!(result.scheme, "opensearchs");
485 }
486
487 #[test]
488 fn test_invalid_scheme_with_space() {
489 let result = parse_uri("bad scheme:path");
490 assert!(result.is_err());
491 match result {
492 Err(CamelError::InvalidUri(msg)) => {
493 assert!(msg.contains("invalid scheme"), "got: {msg}");
494 }
495 _ => panic!("Expected InvalidUri for scheme with space"),
496 }
497 }
498
499 #[test]
500 fn test_invalid_scheme_with_dot() {
501 let result = parse_uri("bad.scheme:path");
502 assert!(result.is_err());
503 match result {
504 Err(CamelError::InvalidUri(msg)) => {
505 assert!(msg.contains("invalid scheme"), "got: {msg}");
506 }
507 _ => panic!("Expected InvalidUri for scheme with dot"),
508 }
509 }
510
511 #[test]
512 fn test_invalid_scheme_with_underscore() {
513 let result = parse_uri("bad_scheme:path");
514 assert!(result.is_err());
515 }
516
517 #[test]
520 fn test_parse_uri_percent_encoded_path() {
521 let result = parse_uri("timer:my%20timer").unwrap();
522 assert_eq!(result.path, "my timer");
523 }
524
525 #[test]
526 fn test_parse_uri_percent_encoded_query_value() {
527 let result = parse_uri("log:info?description=hello%20world").unwrap();
528 assert_eq!(
529 result.params.get("description"),
530 Some(&"hello world".to_string())
531 );
532 }
533
534 #[test]
535 fn test_parse_uri_percent_encoded_special_chars() {
536 let result = parse_uri("http://host/path?user=foo%40bar.com&redirect=%2Fhome").unwrap();
538 assert_eq!(result.params.get("user"), Some(&"foo@bar.com".to_string()));
539 assert_eq!(result.params.get("redirect"), Some(&"/home".to_string()));
540 }
541
542 #[test]
543 fn test_parse_uri_percent_encoded_path_with_slash() {
544 let result = parse_uri("file:my%2Fpath%2Fhere").unwrap();
545 assert_eq!(result.path, "my/path/here");
546 }
547
548 #[test]
549 fn test_raw_value_not_percent_decoded() {
550 let result = parse_uri("redis://localhost?password=RAW(%40secret)").unwrap();
552 assert_eq!(
553 result.params.get("password"),
554 Some(&"%40secret".to_string())
555 );
556 }
557
558 #[test]
559 fn test_percent_encoded_key_decoded() {
560 let result = parse_uri("foo:bar?my%20key=value").unwrap();
561 assert_eq!(result.params.get("my key"), Some(&"value".to_string()));
562 }
563
564 #[test]
565 fn test_invalid_percent_sequence_returns_error() {
566 let result = parse_uri("foo:bar?key=%ZZ");
567 assert!(
568 result.is_err(),
569 "Expected error for invalid percent sequence %ZZ"
570 );
571 }
572
573 #[test]
574 fn test_incomplete_percent_sequence_returns_error() {
575 let result = parse_uri("foo:bar?key=val%");
576 assert!(
577 result.is_err(),
578 "Expected error for incomplete percent sequence"
579 );
580 let result2 = parse_uri("foo:bar?key=val%2");
581 assert!(
582 result2.is_err(),
583 "Expected error for truncated percent sequence"
584 );
585 }
586
587 #[test]
588 fn test_percent_encoded_plus_is_not_space() {
589 let result = parse_uri("foo:bar?key=a+b").unwrap();
591 assert_eq!(result.params.get("key"), Some(&"a+b".to_string()));
592 }
593
594 #[test]
595 fn test_percent_encoded_plus_decodes_to_plus() {
596 let result = parse_uri("file:a%2Bb?key=c%2Bd").unwrap();
598 assert_eq!(result.path, "a+b");
599 assert_eq!(result.params.get("key"), Some(&"c+d".to_string()));
600 }
601
602 #[test]
603 fn test_percent_encoded_multibyte_utf8() {
604 let result = parse_uri("file:caf%C3%A9?name=r%C3%A9sum%C3%A9").unwrap();
606 assert_eq!(result.path, "café");
607 assert_eq!(result.params.get("name"), Some(&"résumé".to_string()));
608 }
609
610 #[test]
611 fn test_percent_encoded_null_byte_allowed() {
612 let result = parse_uri("foo:bar?key=val%00end").unwrap();
614 assert_eq!(result.params.get("key"), Some(&"val\0end".to_string()));
615 }
616
617 #[test]
618 fn test_sensitive_key_percent_encoded() {
619 let result = parse_uri("db:conn?pass%77ord=abc%20def").unwrap();
621 assert_eq!(
623 result.params.get("password"),
624 Some(&"abc%20def".to_string())
625 );
626 }
627
628 #[test]
635 fn parse_uri_preserves_hash_in_path() {
636 let uri = parse_uri("direct://a/b#part").unwrap();
637 assert_eq!(uri.path, "//a/b#part");
638 }
639
640 #[test]
641 fn parse_uri_preserves_hash_in_query_value() {
642 let uri = parse_uri("x:p?key=a#b").unwrap();
643 assert_eq!(uri.params.get("key"), Some(&"a#b".to_string()));
644 }
645
646 #[test]
647 fn parse_uri_percent_encoded_hash_decodes() {
648 let uri = parse_uri("x:p%23q?key=a%23b").unwrap();
649 assert_eq!(uri.path, "p#q");
650 assert_eq!(uri.params.get("key"), Some(&"a#b".to_string()));
651 }
652
653 #[test]
661 fn parse_uri_second_question_preserved_in_value() {
662 let uri = parse_uri("direct://p?a=1?b=2").unwrap();
663 assert_eq!(uri.path, "//p");
664 assert_eq!(uri.params.get("a"), Some(&"1?b=2".to_string()));
665 assert!(
666 !uri.params.contains_key("b"),
667 "second '?' must not start a new parameter"
668 );
669 }
670
671 #[test]
672 fn parse_uri_percent_encoded_question_in_path_decodes() {
673 let uri = parse_uri("x:p%3Fq?key=v").unwrap();
674 assert_eq!(uri.path, "p?q");
675 }
676
677 #[test]
678 fn parse_uri_percent_encoded_question_in_value_decodes() {
679 let uri = parse_uri("x:p?key=a%3Fb").unwrap();
680 assert_eq!(uri.params.get("key"), Some(&"a?b".to_string()));
681 }
682}