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