Skip to main content

camel_endpoint/
uri.rs

1use std::collections::HashMap;
2
3use camel_api::CamelError;
4
5/// Parsed components of a Camel URI.
6///
7/// Format: `scheme:path?key1=value1&key2=value2`
8#[derive(Clone, PartialEq)]
9pub struct UriComponents {
10    /// The scheme (component name), e.g. "timer", "log".
11    pub scheme: String,
12    /// The path portion after the scheme, e.g. "tick" in "timer:tick".
13    pub path: String,
14    /// Query parameters as key-value pairs.
15    pub params: HashMap<String, String>,
16    /// Verbatim authored query bytes (without the leading `?`), preserved
17    /// byte-for-byte at parse time. `Some("")` for a bare trailing `?`,
18    /// `None` when the URI has no query component. Capture never unwraps
19    /// `RAW(...)` wrappers or re-encodes; redaction applies only at display
20    /// surfaces, so `Debug` deliberately omits this field.
21    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
50/// Percent-decode a string per RFC 3986.
51///
52/// `%XX` sequences are replaced by the byte represented by the hex digits.
53/// `+` is NOT treated as space (Camel URIs are not form-encoded).
54/// Returns an error for incomplete or invalid `%XX` sequences, or if the
55/// resulting bytes are not valid UTF-8.
56fn 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
102/// Parse a Camel-style URI into its components.
103///
104/// Format: `scheme:path?key1=value1&key2=value2`
105pub 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    // EP-005: Validate scheme characters — only alphanumeric and hyphens allowed.
115    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    // R4-L2 (b): only the FIRST '?' separates path from query. Any subsequent
125    // '?' is preserved literally inside the query value (no error, no log) —
126    // matching the `#` precedent from ab13389f. Camel endpoint URIs use their
127    // own grammar (`scheme:path?params`), not RFC 3986, so we do not reject a
128    // second '?'. Operators who need a literal '?' in a value can percent-encode
129    // as `%3F` (handled by percent_decode).
130    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            // RAW(...) signals "treat this value literally, no further processing".
168            // For sensitive keys: unwrap the RAW(...) wrapper so the stored value is
169            // the plain secret (consistent with pre-existing sensitive key handling).
170            // For non-sensitive keys: preserve the full `RAW(...)` string intact so
171            // downstream consumers can detect it and handle it explicitly (e.g., avoid
172            // encoding it again). This is intentional, not an oversight.
173            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            // Sensitive non-RAW: preserve literally (no decode)
180            value.to_string()
181        } else {
182            // Non-sensitive non-RAW: percent-decode
183            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
226/// Iterate the raw pairs of a query string, decoding only the keys.
227///
228/// Returns one `(decoded_key, raw_pair)` element per pair: the key is
229/// percent-decoded (so `connect%54imeout` decodes to `connectTimeout`),
230/// while `raw_pair` is the original authored `key=value` slice — values are
231/// never decoded here. This is the wire-fidelity view consumed by the
232/// camel-http raw filter, which forwards authored query bytes while still
233/// matching structured keys.
234///
235/// A malformed percent-escape in a key is an `InvalidUri` error naming the
236/// key; malformed escapes inside values stay raw within their span. Pairs
237/// must already be duplicate-free (enforced by `parse_uri`'s structured
238/// view); this function does not re-check duplicates.
239pub 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            // No '=': the whole span is the key. Bare keys are rejected by
248            // parse_query upstream; here they simply decode as-is.
249            None => pair,
250        };
251        pairs.push((percent_decode(key)?, pair));
252    }
253    Ok(pairs)
254}
255
256/// Parse a boolean parameter from a string, case-insensitively.
257///
258/// Accepts: "true"/"True"/"TRUE"/"1"/"yes" as true,
259///          "false"/"False"/"FALSE"/"0"/"no" as false.
260pub 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    // EP-005: scheme validation tests
505
506    #[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    // ENDPOINT-002: percent-decoding tests
555
556    #[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        // %2F = '/', %3A = ':', %40 = '@'
574        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        // RAW(...) values bypass percent-decoding — they are already raw
588        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        // Camel URIs are NOT form-encoded; '+' is a literal plus, not space
627        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        // %2B must decode to literal '+' in both path and query value
634        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        // %C3%A9 = U+00E9 LATIN SMALL LETTER E WITH ACUTE ('é')
642        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        // %00 decodes to NUL byte — behavior is pinned: decoder allows it, result contains '\0'
650        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        // Key is percent-decoded before sensitivity check; sensitive value is NOT percent-decoded
657        let result = parse_uri("db:conn?pass%77ord=abc%20def").unwrap();
658        // "pass%77ord" decodes key to "password" → sensitive → value stored literal
659        assert_eq!(
660            result.params.get("password"),
661            Some(&"abc%20def".to_string())
662        );
663    }
664
665    // R4-L2 (revised): `#` is NOT stripped — Camel endpoint URIs use their own
666    // grammar (`scheme:path?params`), not RFC 3986. `#` is an established
667    // placeholder character in SQL (`:#name`, positional `#`) and in other
668    // component path/query languages. Operators who need a literal `#` in an
669    // RFC-3986 context can percent-encode as `%23` (handled by percent_decode).
670
671    #[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    // R4-L2 (b) (revised): a second '?' is NOT treated as an error. Only the
691    // first '?' separates path from query; any subsequent '?' is preserved
692    // literally inside the query value (no error, no log). Camel endpoint URIs
693    // use their own grammar (`scheme:path?params`), not RFC 3986. Operators who
694    // need a literal '?' in an RFC-3986 context can percent-encode as `%3F`
695    // (handled by percent_decode). Matches the `#` precedent from ab13389f.
696
697    #[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    // http-query-wire-fidelity: raw query capture must not disturb the
721    // structured params view — duplicate keys keep failing loudly.
722
723    #[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}