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
40impl std::fmt::Debug for UriComponents {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        let mut redacted_params = std::collections::HashMap::new();
43        for (k, v) in &self.params {
44            if is_sensitive_key(k) {
45                redacted_params.insert(k.clone(), "***".to_string());
46            } else {
47                redacted_params.insert(k.clone(), v.clone());
48            }
49        }
50        f.debug_struct("UriComponents")
51            .field("scheme", &self.scheme)
52            .field("path", &self.path)
53            .field("params", &redacted_params)
54            .finish()
55    }
56}
57
58/// Parse a Camel-style URI into its components.
59///
60/// Format: `scheme:path?key1=value1&key2=value2`
61pub fn parse_uri(uri: &str) -> Result<UriComponents, CamelError> {
62    let (scheme, rest) = uri.split_once(':').ok_or_else(|| {
63        CamelError::InvalidUri(format!("missing scheme separator ':' in '{uri}'"))
64    })?;
65
66    if scheme.is_empty() {
67        return Err(CamelError::InvalidUri(format!("empty scheme in '{uri}'")));
68    }
69
70    // EP-005: Validate scheme characters — only alphanumeric and hyphens allowed.
71    if !scheme
72        .chars()
73        .all(|c| c.is_ascii_alphanumeric() || c == '-')
74    {
75        return Err(CamelError::InvalidUri(format!(
76            "invalid scheme '{scheme}': must contain only alphanumeric characters and hyphens"
77        )));
78    }
79
80    let (path, params) = match rest.split_once('?') {
81        Some((path, query)) => (path, parse_query(query)?),
82        None => (rest, HashMap::new()),
83    };
84
85    Ok(UriComponents {
86        scheme: scheme.to_string(),
87        path: path.to_string(),
88        params,
89    })
90}
91
92fn parse_query(query: &str) -> Result<HashMap<String, String>, CamelError> {
93    let mut params = HashMap::new();
94
95    for pair in split_query_pairs(query)
96        .into_iter()
97        .filter(|s| !s.is_empty())
98    {
99        let Some((key, value)) = pair.split_once('=') else {
100            return Err(CamelError::InvalidUri(format!(
101                "query parameter '{}' has no value",
102                pair
103            )));
104        };
105
106        if params.contains_key(key) {
107            return Err(CamelError::InvalidUri(format!(
108                "duplicate query parameter: {}",
109                key
110            )));
111        }
112
113        let parsed_value = if is_sensitive_key(key) {
114            unwrap_raw(value).to_string()
115        } else {
116            value.to_string()
117        };
118
119        params.insert(key.to_string(), parsed_value);
120    }
121
122    Ok(params)
123}
124
125fn split_query_pairs(query: &str) -> Vec<&str> {
126    let mut pairs = Vec::new();
127    let mut start = 0usize;
128    let mut i = 0usize;
129    let mut raw_depth = 0usize;
130
131    while i < query.len() {
132        let rest = &query[i..];
133
134        if rest.starts_with("RAW(") {
135            raw_depth += 1;
136            i += 4;
137            continue;
138        }
139
140        let ch = rest.as_bytes()[0] as char;
141        match ch {
142            ')' if raw_depth > 0 => raw_depth -= 1,
143            '&' if raw_depth == 0 => {
144                pairs.push(&query[start..i]);
145                i += 1;
146                start = i;
147                continue;
148            }
149            _ => {}
150        }
151
152        i += 1;
153    }
154
155    pairs.push(&query[start..]);
156    pairs
157}
158
159/// Parse a boolean parameter from a string, case-insensitively.
160///
161/// Accepts: "true"/"True"/"TRUE"/"1"/"yes" as true,
162///          "false"/"False"/"FALSE"/"0"/"no" as false.
163pub fn parse_bool_param(s: &str) -> Result<bool, String> {
164    match s.to_lowercase().as_str() {
165        "true" | "1" | "yes" => Ok(true),
166        "false" | "0" | "no" => Ok(false),
167        _ => Err(format!("invalid boolean value: '{}'", s)),
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_parse_simple_uri() {
177        let result = parse_uri("timer:tick").unwrap();
178        assert_eq!(result.scheme, "timer");
179        assert_eq!(result.path, "tick");
180        assert!(result.params.is_empty());
181    }
182
183    #[test]
184    fn test_parse_uri_with_params() {
185        let result = parse_uri("timer:tick?period=1000&delay=500").unwrap();
186        assert_eq!(result.scheme, "timer");
187        assert_eq!(result.path, "tick");
188        assert_eq!(result.params.get("period"), Some(&"1000".to_string()));
189        assert_eq!(result.params.get("delay"), Some(&"500".to_string()));
190    }
191
192    #[test]
193    fn test_parse_uri_with_single_param() {
194        let result = parse_uri("log:info?level=debug").unwrap();
195        assert_eq!(result.scheme, "log");
196        assert_eq!(result.path, "info");
197        assert_eq!(result.params.get("level"), Some(&"debug".to_string()));
198    }
199
200    #[test]
201    fn test_parse_uri_no_scheme() {
202        let result = parse_uri("noscheme");
203        assert!(result.is_err());
204    }
205
206    #[test]
207    fn test_parse_uri_empty_scheme() {
208        let result = parse_uri(":path");
209        assert!(result.is_err());
210    }
211
212    #[test]
213    fn test_parse_direct_uri() {
214        let result = parse_uri("direct:myRoute").unwrap();
215        assert_eq!(result.scheme, "direct");
216        assert_eq!(result.path, "myRoute");
217        assert!(result.params.is_empty());
218    }
219
220    #[test]
221    fn test_parse_mock_uri() {
222        let result = parse_uri("mock:result").unwrap();
223        assert_eq!(result.scheme, "mock");
224        assert_eq!(result.path, "result");
225    }
226
227    #[test]
228    fn test_parse_http_uri_simple() {
229        let result = parse_uri("http://localhost:8080/api/users").unwrap();
230        assert_eq!(result.scheme, "http");
231        assert_eq!(result.path, "//localhost:8080/api/users");
232        assert!(result.params.is_empty());
233    }
234
235    #[test]
236    fn test_parse_https_uri_with_camel_params() {
237        let result = parse_uri(
238            "https://api.example.com/v1/data?httpMethod=POST&throwExceptionOnFailure=false",
239        )
240        .unwrap();
241        assert_eq!(result.scheme, "https");
242        assert_eq!(result.path, "//api.example.com/v1/data");
243        assert_eq!(result.params.get("httpMethod"), Some(&"POST".to_string()));
244        assert_eq!(
245            result.params.get("throwExceptionOnFailure"),
246            Some(&"false".to_string())
247        );
248    }
249
250    #[test]
251    fn test_parse_http_uri_no_path() {
252        let result = parse_uri("http://localhost:8080").unwrap();
253        assert_eq!(result.scheme, "http");
254        assert_eq!(result.path, "//localhost:8080");
255        assert!(result.params.is_empty());
256    }
257
258    #[test]
259    fn test_parse_http_uri_with_port_and_query() {
260        let result = parse_uri("http://example.com:3000/api?connectTimeout=5000").unwrap();
261        assert_eq!(result.scheme, "http");
262        assert_eq!(result.path, "//example.com:3000/api");
263        assert_eq!(
264            result.params.get("connectTimeout"),
265            Some(&"5000".to_string())
266        );
267    }
268
269    #[test]
270    fn test_uri_components_debug_redacts_sensitive_params() {
271        let uri = parse_uri("timer:tick?password=secret&token=abc123&name=hello").unwrap();
272        let debug_output = format!("{:?}", uri);
273        assert!(
274            !debug_output.contains("secret"),
275            "Debug must not contain password value"
276        );
277        assert!(
278            !debug_output.contains("abc123"),
279            "Debug must not contain token value"
280        );
281        assert!(
282            debug_output.contains("hello"),
283            "Debug should contain non-sensitive param values"
284        );
285        assert!(
286            debug_output.contains("password"),
287            "Debug should show param key 'password'"
288        );
289    }
290
291    #[test]
292    fn test_uri_components_debug_redacts_case_insensitive() {
293        let uri = parse_uri("timer:tick?Password=secret&TOKEN=abc123").unwrap();
294        let debug_output = format!("{:?}", uri);
295        assert!(
296            !debug_output.contains("secret"),
297            "Debug must redact 'Password' (capitalized)"
298        );
299        assert!(
300            !debug_output.contains("abc123"),
301            "Debug must redact 'TOKEN' (uppercase)"
302        );
303    }
304
305    #[test]
306    fn test_parse_bool_param_true_variants() {
307        for val in &["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
308            assert_eq!(
309                parse_bool_param(val),
310                Ok(true),
311                "parse_bool_param('{}') should be Ok(true)",
312                val
313            );
314        }
315    }
316
317    #[test]
318    fn test_parse_bool_param_false_variants() {
319        for val in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
320            assert_eq!(
321                parse_bool_param(val),
322                Ok(false),
323                "parse_bool_param('{}') should be Ok(false)",
324                val
325            );
326        }
327    }
328
329    #[test]
330    fn test_parse_bool_param_invalid() {
331        for val in &["maybe", "yes ", " true", "2", "-1", ""] {
332            assert!(
333                parse_bool_param(val).is_err(),
334                "parse_bool_param('{}') should be Err",
335                val
336            );
337        }
338    }
339
340    #[test]
341    fn test_raw_token_extracts_value() {
342        assert_eq!(unwrap_raw("RAW(p@ss!)"), "p@ss!");
343        assert_eq!(unwrap_raw("RAW(user:pass@host)"), "user:pass@host");
344    }
345
346    #[test]
347    fn test_non_raw_value_unchanged() {
348        assert_eq!(unwrap_raw("plainvalue"), "plainvalue");
349        assert_eq!(unwrap_raw("RAW(unclosed"), "RAW(unclosed");
350    }
351
352    #[test]
353    fn test_uri_with_raw_password_parses_correctly() {
354        let result = parse_uri("redis://localhost?password=RAW(p@ss!)").unwrap();
355        assert_eq!(result.params.get("password"), Some(&"p@ss!".to_string()));
356    }
357
358    #[test]
359    fn test_uri_with_raw_password_containing_ampersand_parses_correctly() {
360        let result = parse_uri("redis://localhost?password=RAW(a&b)&db=0").unwrap();
361        assert_eq!(result.params.get("password"), Some(&"a&b".to_string()));
362        assert_eq!(result.params.get("db"), Some(&"0".to_string()));
363    }
364
365    #[test]
366    fn test_uri_with_non_sensitive_raw_value_is_unchanged() {
367        let result = parse_uri("timer:tick?name=RAW(p@ss!)").unwrap();
368        assert_eq!(result.params.get("name"), Some(&"RAW(p@ss!)".to_string()));
369    }
370
371    #[test]
372    fn test_parse_uri_duplicate_query_key_returns_error() {
373        let result = parse_uri("foo:bar?key=a&key=b");
374        assert!(result.is_err());
375        match result {
376            Err(CamelError::InvalidUri(msg)) => {
377                assert_eq!(msg, "duplicate query parameter: key");
378            }
379            _ => panic!("Expected InvalidUri for duplicate key"),
380        }
381    }
382
383    #[test]
384    fn test_parse_uri_bare_query_param_returns_error() {
385        let result = parse_uri("foo:bar?flag");
386        assert!(result.is_err());
387        match result {
388            Err(CamelError::InvalidUri(msg)) => {
389                assert_eq!(msg, "query parameter 'flag' has no value");
390            }
391            _ => panic!("Expected InvalidUri for bare query parameter"),
392        }
393    }
394
395    #[test]
396    fn test_parse_uri_duplicate_key_with_raw_ampersand_returns_error() {
397        let result = parse_uri("foo:bar?password=RAW(a&b)&password=RAW(c&d)");
398        assert!(result.is_err());
399        match result {
400            Err(CamelError::InvalidUri(msg)) => {
401                assert_eq!(msg, "duplicate query parameter: password");
402            }
403            _ => panic!("Expected InvalidUri for duplicate key with RAW value"),
404        }
405    }
406
407    // EP-005: scheme validation tests
408
409    #[test]
410    fn test_valid_scheme_alphanumeric() {
411        let result = parse_uri("timer:tick").unwrap();
412        assert_eq!(result.scheme, "timer");
413    }
414
415    #[test]
416    fn test_valid_scheme_with_hyphen() {
417        let result = parse_uri("my-component:path").unwrap();
418        assert_eq!(result.scheme, "my-component");
419    }
420
421    #[test]
422    fn test_valid_scheme_alphanumeric_only() {
423        let result = parse_uri("opensearchs://host:9200/idx").unwrap();
424        assert_eq!(result.scheme, "opensearchs");
425    }
426
427    #[test]
428    fn test_invalid_scheme_with_space() {
429        let result = parse_uri("bad scheme:path");
430        assert!(result.is_err());
431        match result {
432            Err(CamelError::InvalidUri(msg)) => {
433                assert!(msg.contains("invalid scheme"), "got: {msg}");
434            }
435            _ => panic!("Expected InvalidUri for scheme with space"),
436        }
437    }
438
439    #[test]
440    fn test_invalid_scheme_with_dot() {
441        let result = parse_uri("bad.scheme:path");
442        assert!(result.is_err());
443        match result {
444            Err(CamelError::InvalidUri(msg)) => {
445                assert!(msg.contains("invalid scheme"), "got: {msg}");
446            }
447            _ => panic!("Expected InvalidUri for scheme with dot"),
448        }
449    }
450
451    #[test]
452    fn test_invalid_scheme_with_underscore() {
453        let result = parse_uri("bad_scheme:path");
454        assert!(result.is_err());
455    }
456}