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 pub raw_query: Option<String>,
22}
23
24const SENSITIVE_KEYS: &[&str] = &[
25 "password",
26 "secret",
27 "token",
28 "credential",
29 "apikey",
30 "accesskey",
31 "privatekey",
32];
33
34fn is_sensitive_key(key: &str) -> bool {
35 SENSITIVE_KEYS.contains(&key.to_lowercase().as_str())
36}
37
38fn unwrap_raw(value: &str) -> &str {
39 if value.starts_with("RAW(") && value.ends_with(')') {
40 &value[4..value.len() - 1]
41 } else {
42 value
43 }
44}
45
46fn is_raw_value(value: &str) -> bool {
47 value.starts_with("RAW(") && value.ends_with(')')
48}
49
50fn percent_decode(s: &str) -> Result<String, CamelError> {
57 let bytes = s.as_bytes();
58 let mut result = Vec::with_capacity(bytes.len());
59 let mut i = 0;
60 while i < bytes.len() {
61 if bytes[i] == b'%' {
62 if i + 2 >= bytes.len() {
63 return Err(CamelError::InvalidUri(format!(
64 "incomplete percent-encoding at position {i} in '{s}'"
65 )));
66 }
67 let hi = char::from(bytes[i + 1]);
68 let lo = char::from(bytes[i + 2]);
69 let byte = u8::from_str_radix(&format!("{hi}{lo}"), 16).map_err(|_| {
70 CamelError::InvalidUri(format!("invalid percent-encoding '%{hi}{lo}' in '{s}'"))
71 })?;
72 result.push(byte);
73 i += 3;
74 } else {
75 result.push(bytes[i]);
76 i += 1;
77 }
78 }
79 String::from_utf8(result).map_err(|e| {
80 CamelError::InvalidUri(format!("percent-decoded bytes are not valid UTF-8: {e}"))
81 })
82}
83
84impl std::fmt::Debug for UriComponents {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 let mut redacted_params = std::collections::HashMap::new();
87 for (k, v) in &self.params {
88 if is_sensitive_key(k) {
89 redacted_params.insert(k.clone(), "***".to_string());
90 } else {
91 redacted_params.insert(k.clone(), v.clone());
92 }
93 }
94 f.debug_struct("UriComponents")
95 .field("scheme", &self.scheme)
96 .field("path", &self.path)
97 .field("params", &redacted_params)
98 .finish()
99 }
100}
101
102pub fn parse_uri(uri: &str) -> Result<UriComponents, CamelError> {
106 let (scheme, rest) = uri.split_once(':').ok_or_else(|| {
107 CamelError::InvalidUri(format!("missing scheme separator ':' in '{uri}'"))
108 })?;
109
110 if scheme.is_empty() {
111 return Err(CamelError::InvalidUri(format!("empty scheme in '{uri}'")));
112 }
113
114 if !scheme
116 .chars()
117 .all(|c| c.is_ascii_alphanumeric() || c == '-')
118 {
119 return Err(CamelError::InvalidUri(format!(
120 "invalid scheme '{scheme}': must contain only alphanumeric characters and hyphens"
121 )));
122 }
123
124 let (path, raw_query, params) = match rest.split_once('?') {
131 Some((path, query)) => (path, Some(query.to_string()), parse_query(query)?),
132 None => (rest, None, HashMap::new()),
133 };
134
135 Ok(UriComponents {
136 scheme: scheme.to_string(),
137 path: percent_decode(path)?,
138 params,
139 raw_query,
140 })
141}
142
143fn parse_query(query: &str) -> Result<HashMap<String, String>, CamelError> {
144 let mut params = HashMap::new();
145
146 for pair in split_query_pairs(query)
147 .into_iter()
148 .filter(|s| !s.is_empty())
149 {
150 let Some((key, value)) = pair.split_once('=') else {
151 return Err(CamelError::InvalidUri(format!(
152 "query parameter '{}' has no value",
153 pair
154 )));
155 };
156
157 let decoded_key = percent_decode(key)?;
158
159 if params.contains_key(&decoded_key) {
160 return Err(CamelError::InvalidUri(format!(
161 "duplicate query parameter: {}",
162 decoded_key
163 )));
164 }
165
166 let parsed_value = if is_raw_value(value) {
167 if is_sensitive_key(&decoded_key) {
174 unwrap_raw(value).to_string()
175 } else {
176 value.to_string()
177 }
178 } else if is_sensitive_key(&decoded_key) {
179 value.to_string()
181 } else {
182 percent_decode(value)?
184 };
185
186 params.insert(decoded_key, parsed_value);
187 }
188
189 Ok(params)
190}
191
192fn split_query_pairs(query: &str) -> Vec<&str> {
193 let mut pairs = Vec::new();
194 let mut start = 0usize;
195 let mut i = 0usize;
196 let mut raw_depth = 0usize;
197
198 while i < query.len() {
199 let rest = &query[i..];
200
201 if rest.starts_with("RAW(") {
202 raw_depth += 1;
203 i += 4;
204 continue;
205 }
206
207 let ch = rest.as_bytes()[0] as char;
208 match ch {
209 ')' if raw_depth > 0 => raw_depth -= 1,
210 '&' if raw_depth == 0 => {
211 pairs.push(&query[start..i]);
212 i += 1;
213 start = i;
214 continue;
215 }
216 _ => {}
217 }
218
219 i += 1;
220 }
221
222 pairs.push(&query[start..]);
223 pairs
224}
225
226pub fn raw_query_pairs(query: &str) -> Result<Vec<(String, &str)>, CamelError> {
240 let mut pairs = Vec::new();
241 for pair in split_query_pairs(query)
242 .into_iter()
243 .filter(|s| !s.is_empty())
244 {
245 let key = match pair.split_once('=') {
246 Some((key, _)) => key,
247 None => pair,
250 };
251 pairs.push((percent_decode(key)?, pair));
252 }
253 Ok(pairs)
254}
255
256pub fn parse_bool_param(s: &str) -> Result<bool, String> {
261 match s.to_lowercase().as_str() {
262 "true" | "1" | "yes" => Ok(true),
263 "false" | "0" | "no" => Ok(false),
264 _ => Err(format!("invalid boolean value: '{}'", s)),
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_parse_simple_uri() {
274 let result = parse_uri("timer:tick").unwrap();
275 assert_eq!(result.scheme, "timer");
276 assert_eq!(result.path, "tick");
277 assert!(result.params.is_empty());
278 }
279
280 #[test]
281 fn test_parse_uri_with_params() {
282 let result = parse_uri("timer:tick?period=1000&delay=500").unwrap();
283 assert_eq!(result.scheme, "timer");
284 assert_eq!(result.path, "tick");
285 assert_eq!(result.params.get("period"), Some(&"1000".to_string()));
286 assert_eq!(result.params.get("delay"), Some(&"500".to_string()));
287 }
288
289 #[test]
290 fn test_parse_uri_with_single_param() {
291 let result = parse_uri("log:info?level=debug").unwrap();
292 assert_eq!(result.scheme, "log");
293 assert_eq!(result.path, "info");
294 assert_eq!(result.params.get("level"), Some(&"debug".to_string()));
295 }
296
297 #[test]
298 fn test_parse_uri_no_scheme() {
299 let result = parse_uri("noscheme");
300 assert!(result.is_err());
301 }
302
303 #[test]
304 fn test_parse_uri_empty_scheme() {
305 let result = parse_uri(":path");
306 assert!(result.is_err());
307 }
308
309 #[test]
310 fn test_parse_direct_uri() {
311 let result = parse_uri("direct:myRoute").unwrap();
312 assert_eq!(result.scheme, "direct");
313 assert_eq!(result.path, "myRoute");
314 assert!(result.params.is_empty());
315 }
316
317 #[test]
318 fn test_parse_mock_uri() {
319 let result = parse_uri("mock:result").unwrap();
320 assert_eq!(result.scheme, "mock");
321 assert_eq!(result.path, "result");
322 }
323
324 #[test]
325 fn test_parse_http_uri_simple() {
326 let result = parse_uri("http://localhost:8080/api/users").unwrap();
327 assert_eq!(result.scheme, "http");
328 assert_eq!(result.path, "//localhost:8080/api/users");
329 assert!(result.params.is_empty());
330 }
331
332 #[test]
333 fn test_parse_https_uri_with_camel_params() {
334 let result = parse_uri(
335 "https://api.example.com/v1/data?httpMethod=POST&throwExceptionOnFailure=false",
336 )
337 .unwrap();
338 assert_eq!(result.scheme, "https");
339 assert_eq!(result.path, "//api.example.com/v1/data");
340 assert_eq!(result.params.get("httpMethod"), Some(&"POST".to_string()));
341 assert_eq!(
342 result.params.get("throwExceptionOnFailure"),
343 Some(&"false".to_string())
344 );
345 }
346
347 #[test]
348 fn test_parse_http_uri_no_path() {
349 let result = parse_uri("http://localhost:8080").unwrap();
350 assert_eq!(result.scheme, "http");
351 assert_eq!(result.path, "//localhost:8080");
352 assert!(result.params.is_empty());
353 }
354
355 #[test]
356 fn test_parse_http_uri_with_port_and_query() {
357 let result = parse_uri("http://example.com:3000/api?connectTimeout=5000").unwrap();
358 assert_eq!(result.scheme, "http");
359 assert_eq!(result.path, "//example.com:3000/api");
360 assert_eq!(
361 result.params.get("connectTimeout"),
362 Some(&"5000".to_string())
363 );
364 }
365
366 #[test]
367 fn test_uri_components_debug_redacts_sensitive_params() {
368 let uri = parse_uri("timer:tick?password=secret&token=abc123&name=hello").unwrap();
369 let debug_output = format!("{:?}", uri);
370 assert!(
371 !debug_output.contains("secret"),
372 "Debug must not contain password value"
373 );
374 assert!(
375 !debug_output.contains("abc123"),
376 "Debug must not contain token value"
377 );
378 assert!(
379 debug_output.contains("hello"),
380 "Debug should contain non-sensitive param values"
381 );
382 assert!(
383 debug_output.contains("password"),
384 "Debug should show param key 'password'"
385 );
386 }
387
388 #[test]
389 fn test_uri_components_debug_redacts_case_insensitive() {
390 let uri = parse_uri("timer:tick?Password=secret&TOKEN=abc123").unwrap();
391 let debug_output = format!("{:?}", uri);
392 assert!(
393 !debug_output.contains("secret"),
394 "Debug must redact 'Password' (capitalized)"
395 );
396 assert!(
397 !debug_output.contains("abc123"),
398 "Debug must redact 'TOKEN' (uppercase)"
399 );
400 }
401
402 #[test]
403 fn test_parse_bool_param_true_variants() {
404 for val in &["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
405 assert_eq!(
406 parse_bool_param(val),
407 Ok(true),
408 "parse_bool_param('{}') should be Ok(true)",
409 val
410 );
411 }
412 }
413
414 #[test]
415 fn test_parse_bool_param_false_variants() {
416 for val in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
417 assert_eq!(
418 parse_bool_param(val),
419 Ok(false),
420 "parse_bool_param('{}') should be Ok(false)",
421 val
422 );
423 }
424 }
425
426 #[test]
427 fn test_parse_bool_param_invalid() {
428 for val in &["maybe", "yes ", " true", "2", "-1", ""] {
429 assert!(
430 parse_bool_param(val).is_err(),
431 "parse_bool_param('{}') should be Err",
432 val
433 );
434 }
435 }
436
437 #[test]
438 fn test_raw_token_extracts_value() {
439 assert_eq!(unwrap_raw("RAW(p@ss!)"), "p@ss!");
440 assert_eq!(unwrap_raw("RAW(user:pass@host)"), "user:pass@host");
441 }
442
443 #[test]
444 fn test_non_raw_value_unchanged() {
445 assert_eq!(unwrap_raw("plainvalue"), "plainvalue");
446 assert_eq!(unwrap_raw("RAW(unclosed"), "RAW(unclosed");
447 }
448
449 #[test]
450 fn test_uri_with_raw_password_parses_correctly() {
451 let result = parse_uri("redis://localhost?password=RAW(p@ss!)").unwrap();
452 assert_eq!(result.params.get("password"), Some(&"p@ss!".to_string()));
453 }
454
455 #[test]
456 fn test_uri_with_raw_password_containing_ampersand_parses_correctly() {
457 let result = parse_uri("redis://localhost?password=RAW(a&b)&db=0").unwrap();
458 assert_eq!(result.params.get("password"), Some(&"a&b".to_string()));
459 assert_eq!(result.params.get("db"), Some(&"0".to_string()));
460 }
461
462 #[test]
463 fn test_uri_with_non_sensitive_raw_value_is_unchanged() {
464 let result = parse_uri("timer:tick?name=RAW(p@ss!)").unwrap();
465 assert_eq!(result.params.get("name"), Some(&"RAW(p@ss!)".to_string()));
466 }
467
468 #[test]
469 fn test_parse_uri_duplicate_query_key_returns_error() {
470 let result = parse_uri("foo:bar?key=a&key=b");
471 assert!(result.is_err());
472 match result {
473 Err(CamelError::InvalidUri(msg)) => {
474 assert_eq!(msg, "duplicate query parameter: key");
475 }
476 _ => panic!("Expected InvalidUri for duplicate key"),
477 }
478 }
479
480 #[test]
481 fn test_parse_uri_bare_query_param_returns_error() {
482 let result = parse_uri("foo:bar?flag");
483 assert!(result.is_err());
484 match result {
485 Err(CamelError::InvalidUri(msg)) => {
486 assert_eq!(msg, "query parameter 'flag' has no value");
487 }
488 _ => panic!("Expected InvalidUri for bare query parameter"),
489 }
490 }
491
492 #[test]
493 fn test_parse_uri_duplicate_key_with_raw_ampersand_returns_error() {
494 let result = parse_uri("foo:bar?password=RAW(a&b)&password=RAW(c&d)");
495 assert!(result.is_err());
496 match result {
497 Err(CamelError::InvalidUri(msg)) => {
498 assert_eq!(msg, "duplicate query parameter: password");
499 }
500 _ => panic!("Expected InvalidUri for duplicate key with RAW value"),
501 }
502 }
503
504 #[test]
507 fn test_valid_scheme_alphanumeric() {
508 let result = parse_uri("timer:tick").unwrap();
509 assert_eq!(result.scheme, "timer");
510 }
511
512 #[test]
513 fn test_valid_scheme_with_hyphen() {
514 let result = parse_uri("my-component:path").unwrap();
515 assert_eq!(result.scheme, "my-component");
516 }
517
518 #[test]
519 fn test_valid_scheme_alphanumeric_only() {
520 let result = parse_uri("opensearchs://host:9200/idx").unwrap();
521 assert_eq!(result.scheme, "opensearchs");
522 }
523
524 #[test]
525 fn test_invalid_scheme_with_space() {
526 let result = parse_uri("bad scheme:path");
527 assert!(result.is_err());
528 match result {
529 Err(CamelError::InvalidUri(msg)) => {
530 assert!(msg.contains("invalid scheme"), "got: {msg}");
531 }
532 _ => panic!("Expected InvalidUri for scheme with space"),
533 }
534 }
535
536 #[test]
537 fn test_invalid_scheme_with_dot() {
538 let result = parse_uri("bad.scheme:path");
539 assert!(result.is_err());
540 match result {
541 Err(CamelError::InvalidUri(msg)) => {
542 assert!(msg.contains("invalid scheme"), "got: {msg}");
543 }
544 _ => panic!("Expected InvalidUri for scheme with dot"),
545 }
546 }
547
548 #[test]
549 fn test_invalid_scheme_with_underscore() {
550 let result = parse_uri("bad_scheme:path");
551 assert!(result.is_err());
552 }
553
554 #[test]
557 fn test_parse_uri_percent_encoded_path() {
558 let result = parse_uri("timer:my%20timer").unwrap();
559 assert_eq!(result.path, "my timer");
560 }
561
562 #[test]
563 fn test_parse_uri_percent_encoded_query_value() {
564 let result = parse_uri("log:info?description=hello%20world").unwrap();
565 assert_eq!(
566 result.params.get("description"),
567 Some(&"hello world".to_string())
568 );
569 }
570
571 #[test]
572 fn test_parse_uri_percent_encoded_special_chars() {
573 let result = parse_uri("http://host/path?user=foo%40bar.com&redirect=%2Fhome").unwrap();
575 assert_eq!(result.params.get("user"), Some(&"foo@bar.com".to_string()));
576 assert_eq!(result.params.get("redirect"), Some(&"/home".to_string()));
577 }
578
579 #[test]
580 fn test_parse_uri_percent_encoded_path_with_slash() {
581 let result = parse_uri("file:my%2Fpath%2Fhere").unwrap();
582 assert_eq!(result.path, "my/path/here");
583 }
584
585 #[test]
586 fn test_raw_value_not_percent_decoded() {
587 let result = parse_uri("redis://localhost?password=RAW(%40secret)").unwrap();
589 assert_eq!(
590 result.params.get("password"),
591 Some(&"%40secret".to_string())
592 );
593 }
594
595 #[test]
596 fn test_percent_encoded_key_decoded() {
597 let result = parse_uri("foo:bar?my%20key=value").unwrap();
598 assert_eq!(result.params.get("my key"), Some(&"value".to_string()));
599 }
600
601 #[test]
602 fn test_invalid_percent_sequence_returns_error() {
603 let result = parse_uri("foo:bar?key=%ZZ");
604 assert!(
605 result.is_err(),
606 "Expected error for invalid percent sequence %ZZ"
607 );
608 }
609
610 #[test]
611 fn test_incomplete_percent_sequence_returns_error() {
612 let result = parse_uri("foo:bar?key=val%");
613 assert!(
614 result.is_err(),
615 "Expected error for incomplete percent sequence"
616 );
617 let result2 = parse_uri("foo:bar?key=val%2");
618 assert!(
619 result2.is_err(),
620 "Expected error for truncated percent sequence"
621 );
622 }
623
624 #[test]
625 fn test_percent_encoded_plus_is_not_space() {
626 let result = parse_uri("foo:bar?key=a+b").unwrap();
628 assert_eq!(result.params.get("key"), Some(&"a+b".to_string()));
629 }
630
631 #[test]
632 fn test_percent_encoded_plus_decodes_to_plus() {
633 let result = parse_uri("file:a%2Bb?key=c%2Bd").unwrap();
635 assert_eq!(result.path, "a+b");
636 assert_eq!(result.params.get("key"), Some(&"c+d".to_string()));
637 }
638
639 #[test]
640 fn test_percent_encoded_multibyte_utf8() {
641 let result = parse_uri("file:caf%C3%A9?name=r%C3%A9sum%C3%A9").unwrap();
643 assert_eq!(result.path, "café");
644 assert_eq!(result.params.get("name"), Some(&"résumé".to_string()));
645 }
646
647 #[test]
648 fn test_percent_encoded_null_byte_allowed() {
649 let result = parse_uri("foo:bar?key=val%00end").unwrap();
651 assert_eq!(result.params.get("key"), Some(&"val\0end".to_string()));
652 }
653
654 #[test]
655 fn test_sensitive_key_percent_encoded() {
656 let result = parse_uri("db:conn?pass%77ord=abc%20def").unwrap();
658 assert_eq!(
660 result.params.get("password"),
661 Some(&"abc%20def".to_string())
662 );
663 }
664
665 #[test]
672 fn parse_uri_preserves_hash_in_path() {
673 let uri = parse_uri("direct://a/b#part").unwrap();
674 assert_eq!(uri.path, "//a/b#part");
675 }
676
677 #[test]
678 fn parse_uri_preserves_hash_in_query_value() {
679 let uri = parse_uri("x:p?key=a#b").unwrap();
680 assert_eq!(uri.params.get("key"), Some(&"a#b".to_string()));
681 }
682
683 #[test]
684 fn parse_uri_percent_encoded_hash_decodes() {
685 let uri = parse_uri("x:p%23q?key=a%23b").unwrap();
686 assert_eq!(uri.path, "p#q");
687 assert_eq!(uri.params.get("key"), Some(&"a#b".to_string()));
688 }
689
690 #[test]
698 fn parse_uri_second_question_preserved_in_value() {
699 let uri = parse_uri("direct://p?a=1?b=2").unwrap();
700 assert_eq!(uri.path, "//p");
701 assert_eq!(uri.params.get("a"), Some(&"1?b=2".to_string()));
702 assert!(
703 !uri.params.contains_key("b"),
704 "second '?' must not start a new parameter"
705 );
706 }
707
708 #[test]
709 fn parse_uri_percent_encoded_question_in_path_decodes() {
710 let uri = parse_uri("x:p%3Fq?key=v").unwrap();
711 assert_eq!(uri.path, "p?q");
712 }
713
714 #[test]
715 fn parse_uri_percent_encoded_question_in_value_decodes() {
716 let uri = parse_uri("x:p?key=a%3Fb").unwrap();
717 assert_eq!(uri.params.get("key"), Some(&"a?b".to_string()));
718 }
719
720 #[test]
724 fn raw_query_preserves_authored_bytes() {
725 let uri = parse_uri("scheme://host/p?a=1&b=x%2Cy&c=t:1").unwrap();
726 assert_eq!(uri.raw_query, Some("a=1&b=x%2Cy&c=t:1".to_string()));
727 assert_eq!(uri.params.get("b"), Some(&"x,y".to_string()));
728 }
729
730 #[test]
731 fn raw_query_absent_is_none() {
732 let uri = parse_uri("scheme://host/p").unwrap();
733 assert!(uri.raw_query.is_none());
734 }
735
736 #[test]
737 fn raw_query_empty_marker_is_empty_string() {
738 let uri = parse_uri("scheme://host/p?").unwrap();
739 assert_eq!(uri.raw_query, Some(String::new()));
740 }
741
742 #[test]
743 fn raw_query_preserves_raw_wrapper_text() {
744 let uri = parse_uri("scheme://host/p?token=RAW(abc123)").unwrap();
745 assert_eq!(uri.raw_query, Some("token=RAW(abc123)".to_string()));
746 }
747
748 #[test]
749 fn raw_query_pairs_decodes_keys_keeps_raw_spans() {
750 let pairs = raw_query_pairs("a=1&connect%54imeout=5s&b=x%2Cy").unwrap();
751 let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
752 let spans: Vec<&str> = pairs.iter().map(|(_, span)| *span).collect();
753 assert_eq!(keys, ["a", "connectTimeout", "b"]);
754 assert_eq!(spans, ["a=1", "connect%54imeout=5s", "b=x%2Cy"]);
755 }
756
757 #[test]
758 fn raw_query_pairs_malformed_key_escape_errors() {
759 match raw_query_pairs("%zz=1") {
760 Err(CamelError::InvalidUri(msg)) => {
761 assert!(msg.contains("%zz"), "error must name the key, got: {msg}");
762 }
763 _ => panic!("Expected InvalidUri naming the malformed key"),
764 }
765 }
766
767 #[test]
768 fn raw_query_pairs_malformed_value_escape_stays_raw() {
769 let pairs = raw_query_pairs("a=%zz").unwrap();
770 assert_eq!(pairs, vec![("a".to_string(), "a=%zz")]);
771 }
772
773 #[test]
774 fn duplicate_keys_still_rejected() {
775 let result = parse_uri("scheme://host/p?a=1&a=2");
776 match result {
777 Err(CamelError::InvalidUri(msg)) => {
778 assert_eq!(msg, "duplicate query parameter: a");
779 }
780 _ => panic!("Expected InvalidUri for duplicate key"),
781 }
782 }
783}