1use std::collections::HashMap;
2
3use camel_api::CamelError;
4
5#[derive(Clone, PartialEq)]
9pub struct UriComponents {
10 pub scheme: String,
12 pub path: String,
14 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
58pub 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 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
159pub 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 #[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}