1use crate::component_metadata::{ComponentMetadata, ComponentMetadataCatalog, UriOption};
5use crate::error::EndpointUriError;
6use std::borrow::Cow;
7use std::collections::BTreeMap;
8use std::fmt;
9
10#[non_exhaustive]
20#[derive(Clone)]
21pub struct EndpointUri {
22 pub scheme: String,
24 pub path: String,
26 pub params: BTreeMap<String, String>,
28 raw_query: Option<String>,
30}
31
32impl EndpointUri {
33 pub fn try_from_uri_and_params(
39 base: &str,
40 params: BTreeMap<String, String>,
41 ) -> Result<Self, EndpointUriError> {
42 let colon = base.find(':').ok_or(EndpointUriError::MissingScheme)?;
43 let scheme = &base[..colon];
44 if scheme.is_empty() {
45 return Err(EndpointUriError::MissingScheme);
46 }
47
48 let rest = &base[colon + 1..];
49 let (path, raw_query) = match rest.find('?') {
50 Some(q) => (&rest[..q], Some(rest[q + 1..].to_string())),
51 None => (rest, None),
52 };
53
54 let mut query_keys: Vec<String> = Vec::new();
56 if let Some(query) = raw_query.as_deref() {
57 for pair in query.split('&') {
58 let key = pair.split_once('=').map_or(pair, |(key, _)| key);
59 if key.is_empty() {
60 return Err(EndpointUriError::EmptyQueryKey);
61 }
62 query_keys.push(key.to_string());
63 }
64 }
65
66 for key in params.keys() {
68 if key.is_empty()
69 || key
70 .bytes()
71 .any(|b| matches!(b, b'&' | b'=' | b'%' | b'#' | b'?' | b'+' | b' '))
72 {
73 return Err(EndpointUriError::InvalidParamKey { key: key.clone() });
74 }
75 }
76
77 for key in params.keys() {
79 if query_keys.iter().any(|query_key| query_key == key) {
80 return Err(EndpointUriError::DuplicateKey { key: key.clone() });
81 }
82 }
83
84 Ok(EndpointUri {
85 scheme: scheme.to_string(),
86 path: path.to_string(),
87 params,
88 raw_query,
89 })
90 }
91
92 pub fn to_canonical_string(&self) -> String {
95 let mut out =
96 String::with_capacity(self.scheme.len() + self.path.len() + self.params.len() * 16);
97 out.push_str(&self.scheme);
98 out.push(':');
99 out.push_str(&self.path);
100
101 for (i, pair) in self.pairs().enumerate() {
102 out.push(if i == 0 { '?' } else { '&' });
103 push_pair(&mut out, &pair, |value| value);
104 }
105
106 out
107 }
108
109 pub fn to_redacted_string(&self, catalog: &dyn ComponentMetadataCatalog) -> String {
119 let metadata = catalog.get_metadata(&self.scheme);
120 let mut out =
121 String::with_capacity(self.scheme.len() + self.path.len() + self.params.len() * 16);
122 out.push_str(&self.scheme);
123 out.push(':');
124 out.push_str(&mask_userinfo(&self.path));
125
126 for (i, pair) in self.pairs().enumerate() {
127 out.push(if i == 0 { '?' } else { '&' });
128 push_pair(&mut out, &pair, |value| {
129 redact_value(pair.key, value, metadata.as_ref())
130 });
131 }
132
133 out
134 }
135
136 fn pairs(&self) -> impl Iterator<Item = RenderPair<'_>> {
140 let raw = self
141 .raw_query
142 .as_deref()
143 .into_iter()
144 .flat_map(|query| query.split('&'))
145 .map(|pair| match pair.split_once('=') {
146 Some((key, value)) => RenderPair {
147 key,
148 value: Some(value),
149 from_raw_query: true,
150 },
151 None => RenderPair {
152 key: pair,
153 value: None,
154 from_raw_query: true,
155 },
156 });
157 let params = self.params.iter().map(|(key, value)| {
158 debug_assert!(
161 !key.is_empty()
162 && !key
163 .bytes()
164 .any(|b| matches!(b, b'&' | b'=' | b'%' | b'#' | b'?' | b'+' | b' ')),
165 "EndpointUri.params key violates the key policy: {key:?}"
166 );
167 RenderPair {
168 key: key.as_str(),
169 value: Some(value.as_str()),
170 from_raw_query: false,
171 }
172 });
173 raw.chain(params)
174 }
175}
176
177impl fmt::Debug for EndpointUri {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.debug_struct("EndpointUri")
180 .field("scheme", &self.scheme)
181 .field("path", &mask_userinfo(&self.path))
182 .field("params", &RedactedParams(&self.params))
183 .finish()
184 }
185}
186
187struct RedactedParams<'a>(&'a BTreeMap<String, String>);
191
192impl fmt::Debug for RedactedParams<'_> {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 let mut map = f.debug_map();
195 for key in self.0.keys() {
196 map.entry(key, &"***");
197 }
198 map.finish()
199 }
200}
201
202fn push_percent_encoded(out: &mut String, value: &str) {
206 for ch in value.chars() {
207 match ch {
208 '&' => out.push_str("%26"),
209 '=' => out.push_str("%3D"),
210 '%' => out.push_str("%25"),
211 '#' => out.push_str("%23"),
212 '?' => out.push_str("%3F"),
213 '+' => out.push_str("%2B"),
214 ' ' => out.push_str("%20"),
215 _ => out.push(ch),
216 }
217 }
218}
219
220fn resolve_uri_option<'a>(key: &str, uri_options: &'a [UriOption]) -> Option<&'a UriOption> {
224 uri_options
225 .iter()
226 .find(|uo| uo.pattern.is_none() && uo.name == key)
227 .or_else(|| {
228 uri_options
229 .iter()
230 .find(|uo| uo.pattern.is_none() && uo.aliases.iter().any(|alias| alias == key))
231 })
232}
233
234fn redact_value<'a>(key: &str, value: &'a str, metadata: Option<&ComponentMetadata>) -> &'a str {
238 let non_secret = metadata
239 .and_then(|meta| resolve_uri_option(key, &meta.uri_options))
240 .is_some_and(|opt| !opt.secret);
241 if non_secret { value } else { "***" }
242}
243
244fn mask_userinfo(path: &str) -> Cow<'_, str> {
251 let rest = match path.strip_prefix("//") {
252 Some(rest) => rest,
253 None => return Cow::Borrowed(path),
254 };
255 let authority_end = rest.find('/').unwrap_or(rest.len());
256 let authority = &rest[..authority_end];
257 let Some(at) = authority.find('@') else {
258 return Cow::Borrowed(path);
259 };
260 let mut masked = String::with_capacity(path.len() + 3);
261 masked.push_str("//***");
262 masked.push_str(&authority[at..]);
263 masked.push_str(&rest[authority_end..]);
264 Cow::Owned(masked)
265}
266
267struct RenderPair<'a> {
269 key: &'a str,
270 value: Option<&'a str>,
272 from_raw_query: bool,
275}
276
277fn push_pair<'a, F>(out: &mut String, pair: &RenderPair<'a>, transform: F)
282where
283 F: Fn(&'a str) -> &'a str,
284{
285 out.push_str(pair.key);
286 if let Some(value) = pair.value {
287 out.push('=');
288 if pair.from_raw_query {
289 out.push_str(transform(value));
290 } else {
291 push_percent_encoded(out, transform(value));
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::component_metadata::{
300 ComponentMetadata, ComponentMetadataCatalog, OptionKind, UriOption,
301 };
302 use std::collections::BTreeMap;
303
304 fn params(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
305 pairs
306 .iter()
307 .map(|(k, v)| (k.to_string(), v.to_string()))
308 .collect()
309 }
310
311 #[test]
312 fn merge_uri_and_params_canonical() {
313 let uri = EndpointUri::try_from_uri_and_params(
314 "kafka:orders",
315 params(&[("brokers", "my-host:9092"), ("acks", "all")]),
316 )
317 .unwrap();
318 assert_eq!(
319 uri.to_canonical_string(),
320 "kafka:orders?acks=all&brokers=my-host:9092"
321 );
322 }
323
324 #[test]
325 fn duplicate_key_fails_closed() {
326 let err = EndpointUri::try_from_uri_and_params(
327 "kafka:orders?brokers=a",
328 params(&[("brokers", "b")]),
329 )
330 .unwrap_err();
331 assert_eq!(
332 err,
333 EndpointUriError::DuplicateKey {
334 key: "brokers".to_string()
335 }
336 );
337 assert!(err.to_string().contains("brokers"));
338 }
339
340 #[test]
341 fn repeated_query_keys_preserved() {
342 let uri =
343 EndpointUri::try_from_uri_and_params("list:demo?item=a&item=b", params(&[])).unwrap();
344 assert_eq!(uri.to_canonical_string(), "list:demo?item=a&item=b");
345 }
346
347 #[test]
348 fn malformed_bases_rejected() {
349 let err = EndpointUri::try_from_uri_and_params("noscheme", params(&[])).unwrap_err();
351 assert_eq!(err, EndpointUriError::MissingScheme);
352 assert!(err.to_string().contains("scheme"));
353
354 let err = EndpointUri::try_from_uri_and_params(":pathonly", params(&[])).unwrap_err();
356 assert_eq!(err, EndpointUriError::MissingScheme);
357 assert!(err.to_string().contains("scheme"));
358
359 let err = EndpointUri::try_from_uri_and_params("timer:tick?=1", params(&[])).unwrap_err();
361 assert_eq!(err, EndpointUriError::EmptyQueryKey);
362 assert!(err.to_string().contains("empty key"));
363
364 let err = EndpointUri::try_from_uri_and_params("kafka:orders", params(&[("a&b", "1")]))
366 .unwrap_err();
367 assert_eq!(
368 err,
369 EndpointUriError::InvalidParamKey {
370 key: "a&b".to_string()
371 }
372 );
373 assert!(err.to_string().contains("a&b"));
374 }
375
376 #[test]
377 fn deterministic_across_insert_orders() {
378 let a =
379 EndpointUri::try_from_uri_and_params("x:y", params(&[("b", "2"), ("a", "1")])).unwrap();
380 let b =
381 EndpointUri::try_from_uri_and_params("x:y", params(&[("a", "1"), ("b", "2")])).unwrap();
382 assert_eq!(a.to_canonical_string(), b.to_canonical_string());
383 }
384
385 #[test]
386 fn existing_query_preserved_byte_identical() {
387 let input = "timer:tick?period=1000&repeatCount=6";
388 let uri = EndpointUri::try_from_uri_and_params(input, params(&[])).unwrap();
389 assert_eq!(uri.to_canonical_string(), input);
390 }
391
392 #[test]
393 fn golden_reserved_characters() {
394 let uri = EndpointUri::try_from_uri_and_params(
395 "http:srv?a=1&flag",
396 params(&[("z", "100%"), ("q", "a b+c")]),
397 )
398 .unwrap();
399 assert_eq!(
400 uri.to_canonical_string(),
401 "http:srv?a=1&flag&q=a%20b%2Bc&z=100%25"
402 );
403 }
404
405 #[test]
406 fn pair_without_equals_has_empty_value() {
407 let uri = EndpointUri::try_from_uri_and_params("t:x?flag", params(&[("a", "1")])).unwrap();
408 assert_eq!(uri.to_canonical_string(), "t:x?flag&a=1");
409 }
410
411 struct StubCatalog {
416 entries: BTreeMap<String, ComponentMetadata>,
417 }
418
419 impl ComponentMetadataCatalog for StubCatalog {
420 fn get_metadata(&self, scheme: &str) -> Option<ComponentMetadata> {
421 self.entries.get(scheme).cloned()
422 }
423
424 fn schemes(&self) -> Vec<String> {
425 self.entries.keys().cloned().collect()
426 }
427
428 fn all_metadata(&self) -> Vec<ComponentMetadata> {
429 self.entries.values().cloned().collect()
430 }
431 }
432
433 fn stub_catalog() -> StubCatalog {
437 let password = UriOption::new("password", "password", OptionKind::String).secret();
438 let timeout = UriOption::new("timeout", "timeout", OptionKind::String);
439 let token = UriOption::new("token", "token", OptionKind::String).with_alias("apikey");
440 let cfg = UriOption::new("cfg", "cfg", OptionKind::String).pattern_prefix("cfg.");
441 let meta = ComponentMetadata::minimal("http")
442 .with_uri_options(vec![password, timeout, token, cfg]);
443 let mut entries = BTreeMap::new();
444 entries.insert("http".to_string(), meta);
445 StubCatalog { entries }
446 }
447
448 #[test]
449 fn debug_masks_all_param_values() {
450 let uri = EndpointUri::try_from_uri_and_params(
451 "http:srv?password=clear",
452 params(&[("delay", "1000")]),
453 )
454 .unwrap();
455 let debug = format!("{uri:?}");
456 assert!(debug.contains("***"));
457 assert!(!debug.contains("1000"));
458 assert!(!debug.contains("clear"));
459 }
460
461 #[test]
462 fn debug_and_redacted_mask_userinfo() {
463 let catalog = stub_catalog();
464 let uri =
465 EndpointUri::try_from_uri_and_params("http://admin:hunter2@srv/path", params(&[]))
466 .unwrap();
467 let debug = format!("{uri:?}");
468 let redacted = uri.to_redacted_string(&catalog);
469 for out in [&debug, &redacted] {
470 assert!(!out.contains("hunter2"), "userinfo leak in {out}");
471 assert!(!out.contains("admin:"), "userinfo leak in {out}");
472 }
473 assert_eq!(uri.to_canonical_string(), "http://admin:hunter2@srv/path");
474 }
475
476 #[test]
477 fn redacted_string_masks_secret_passes_non_secret() {
478 let catalog = stub_catalog();
479 let uri = EndpointUri::try_from_uri_and_params(
480 "http:srv",
481 params(&[("password", "hunter2"), ("timeout", "5000")]),
482 )
483 .unwrap();
484 let out = uri.to_redacted_string(&catalog);
485 assert!(out.contains("password=***"));
486 assert!(out.contains("timeout=5000"));
487 }
488
489 #[test]
490 fn redacted_string_unknown_scheme_masks() {
491 let catalog = stub_catalog();
492 let uri =
493 EndpointUri::try_from_uri_and_params("not-a-scheme:dest", params(&[("token", "abc")]))
494 .unwrap();
495 let out = uri.to_redacted_string(&catalog);
496 assert!(out.contains("token=***"));
497 assert!(!out.contains("abc"));
498 }
499
500 #[test]
501 fn redacted_string_masks_query_string_secrets() {
502 let catalog = stub_catalog();
503 let uri =
504 EndpointUri::try_from_uri_and_params("http:srv?password=clear", params(&[])).unwrap();
505 let out = uri.to_redacted_string(&catalog);
506 assert!(out.contains("password=***"));
507 assert!(!out.contains("clear"));
508 }
509
510 #[test]
511 fn redacted_string_alias_resolves_pattern_anchor_does_not() {
512 let catalog = stub_catalog();
513 let uri = EndpointUri::try_from_uri_and_params(
514 "http:srv",
515 params(&[("apikey", "abc"), ("cfg.foo", "bar")]),
516 )
517 .unwrap();
518 let out = uri.to_redacted_string(&catalog);
519 assert!(out.contains("apikey=abc"));
520 assert!(out.contains("cfg.foo=***"));
521 }
522}