Skip to main content

camel_endpoint/
uri.rs

1use std::collections::HashMap;
2
3use camel_api::CamelError;
4use camel_api::redact::redact_url_fail_closed;
5
6/// Parsed components of a Camel URI.
7///
8/// Format: `scheme:path?key1=value1&key2=value2`
9#[derive(Clone, PartialEq)]
10pub struct UriComponents {
11    /// The scheme (component name), e.g. "timer", "log".
12    pub scheme: String,
13    /// The path portion after the scheme, e.g. "tick" in "timer:tick".
14    pub path: String,
15    /// Query parameters as key-value pairs.
16    pub params: HashMap<String, String>,
17    /// Verbatim authored query bytes (without the leading `?`), preserved
18    /// byte-for-byte at parse time. `Some("")` for a bare trailing `?`,
19    /// `None` when the URI has no query component. Capture never unwraps
20    /// `RAW(...)` wrappers or re-encodes; redaction applies only at display
21    /// surfaces, so `Debug` deliberately omits this field.
22    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
51/// Percent-decode a string per RFC 3986.
52///
53/// `%XX` sequences are replaced by the byte represented by the hex digits.
54/// `+` is NOT treated as space (Camel URIs are not form-encoded).
55/// Returns an error for incomplete or invalid `%XX` sequences, or if the
56/// resulting bytes are not valid UTF-8.
57fn 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
103/// Parse a Camel-style URI into its components.
104///
105/// Format: `scheme:path?key1=value1&key2=value2`
106pub fn parse_uri(uri: &str) -> Result<UriComponents, CamelError> {
107    // Audit 2026-08-31 F5-4: error paths echo the URI through the
108    // string-layer canonical redactor — the raw bytes never leave this
109    // function unmasked.
110    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    // EP-005: Validate scheme characters — only alphanumeric and hyphens allowed.
125    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    // R4-L2 (b): only the FIRST '?' separates path from query. Any subsequent
135    // '?' is preserved literally inside the query value (no error, no log) —
136    // matching the `#` precedent from ab13389f. Camel endpoint URIs use their
137    // own grammar (`scheme:path?params`), not RFC 3986, so we do not reject a
138    // second '?'. Operators who need a literal '?' in a value can percent-encode
139    // as `%3F` (handled by percent_decode).
140    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            // RAW(...) signals "treat this value literally, no further processing".
178            // For sensitive keys: unwrap the RAW(...) wrapper so the stored value is
179            // the plain secret (consistent with pre-existing sensitive key handling).
180            // For non-sensitive keys: preserve the full `RAW(...)` string intact so
181            // downstream consumers can detect it and handle it explicitly (e.g., avoid
182            // encoding it again). This is intentional, not an oversight.
183            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            // Sensitive non-RAW: preserve literally (no decode)
190            value.to_string()
191        } else {
192            // Non-sensitive non-RAW: percent-decode
193            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
236/// Iterate the raw pairs of a query string, decoding only the keys.
237///
238/// Returns one `(decoded_key, raw_pair)` element per pair: the key is
239/// percent-decoded (so `connect%54imeout` decodes to `connectTimeout`),
240/// while `raw_pair` is the original authored `key=value` slice — values are
241/// never decoded here. This is the wire-fidelity view consumed by the
242/// camel-http raw filter, which forwards authored query bytes while still
243/// matching structured keys.
244///
245/// A malformed percent-escape in a key is an `InvalidUri` error naming the
246/// key; malformed escapes inside values stay raw within their span. Pairs
247/// must already be duplicate-free (enforced by `parse_uri`'s structured
248/// view); this function does not re-check duplicates.
249pub 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            // No '=': the whole span is the key. Bare keys are rejected by
258            // parse_query upstream; here they simply decode as-is.
259            None => pair,
260        };
261        pairs.push((percent_decode(key)?, pair));
262    }
263    Ok(pairs)
264}
265
266/// Parse a boolean parameter from a string, case-insensitively.
267///
268/// Accepts: "true"/"True"/"TRUE"/"1"/"yes" as true,
269///          "false"/"False"/"FALSE"/"0"/"no" as false.
270pub 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    // Audit 2026-08-31 F5-4 (rc-a67at): URI-echoing parse errors route the
314    // raw URI through the string-layer canonical redactor
315    // (`camel_api::redact::redact_url_fail_closed`) so query credentials
316    // never reach logs or operator-facing error surfaces.
317
318    #[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        // ':' scheme separator present but empty: the whole URI — including
336        // an authority-shaped remainder and its query — is echoed. Fail
337        // closed: an authority window carrying `@` replaces the whole
338        // rendering with `[redacted]`.
339        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    // EP-005: scheme validation tests
552
553    #[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    // ENDPOINT-002: percent-decoding tests
602
603    #[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        // %2F = '/', %3A = ':', %40 = '@'
621        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        // RAW(...) values bypass percent-decoding — they are already raw
635        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        // Camel URIs are NOT form-encoded; '+' is a literal plus, not space
674        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        // %2B must decode to literal '+' in both path and query value
681        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        // %C3%A9 = U+00E9 LATIN SMALL LETTER E WITH ACUTE ('é')
689        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        // %00 decodes to NUL byte — behavior is pinned: decoder allows it, result contains '\0'
697        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        // Key is percent-decoded before sensitivity check; sensitive value is NOT percent-decoded
704        let result = parse_uri("db:conn?pass%77ord=abc%20def").unwrap();
705        // "pass%77ord" decodes key to "password" → sensitive → value stored literal
706        assert_eq!(
707            result.params.get("password"),
708            Some(&"abc%20def".to_string())
709        );
710    }
711
712    // R4-L2 (revised): `#` is NOT stripped — Camel endpoint URIs use their own
713    // grammar (`scheme:path?params`), not RFC 3986. `#` is an established
714    // placeholder character in SQL (`:#name`, positional `#`) and in other
715    // component path/query languages. Operators who need a literal `#` in an
716    // RFC-3986 context can percent-encode as `%23` (handled by percent_decode).
717
718    #[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    // R4-L2 (b) (revised): a second '?' is NOT treated as an error. Only the
738    // first '?' separates path from query; any subsequent '?' is preserved
739    // literally inside the query value (no error, no log). Camel endpoint URIs
740    // use their own grammar (`scheme:path?params`), not RFC 3986. Operators who
741    // need a literal '?' in an RFC-3986 context can percent-encode as `%3F`
742    // (handled by percent_decode). Matches the `#` precedent from ab13389f.
743
744    #[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    // http-query-wire-fidelity: raw query capture must not disturb the
768    // structured params view — duplicate keys keep failing loudly.
769
770    #[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}