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