Skip to main content

camel_api/
endpoint_uri.rs

1//! Endpoint URI value type: typed fail-closed merge of a base URI with a `parameters:` map
2//! and deterministic canonical rendering.
3
4use crate::component_metadata::{ComponentMetadata, ComponentMetadataCatalog, UriOption};
5use crate::error::EndpointUriError;
6use std::borrow::Cow;
7use std::collections::BTreeMap;
8use std::fmt;
9
10/// A parsed route endpoint URI: a scheme, a path, and a parameter map, plus the original
11/// query bytes for byte-preserving rendering.
12///
13/// The private `raw_query` field stores the base URI's query exactly as it appeared (without
14/// the leading `?`), or `None` when the base URI had no query. `to_canonical_string` replays
15/// those bytes verbatim and then appends the `parameters:` entries in sorted order, so
16/// rendering is deterministic and never rewrites the caller's original query.
17///
18/// ADR-0051 credential boundary: redacting-wrapper
19#[non_exhaustive]
20#[derive(Clone)]
21pub struct EndpointUri {
22    /// URI scheme: the non-empty substring before the first `:`.
23    pub scheme: String,
24    /// URI path: everything after the first `:` up to the first `?`.
25    pub path: String,
26    /// DSL `parameters:` map, keyed by parameter name, rendered in sorted order.
27    pub params: BTreeMap<String, String>,
28    /// Original query bytes (without the leading `?`), `None` when the base URI had no query.
29    raw_query: Option<String>,
30}
31
32impl EndpointUri {
33    /// Merge a base URI with a `parameters:` map, failing closed on malformed input.
34    ///
35    /// The base URI must have a non-empty scheme. Query pairs in the base URI are parsed only
36    /// to detect empty keys and to reject `parameters:` keys that collide with a raw query key;
37    /// the query text itself is preserved byte-for-byte for rendering.
38    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        // Collect the raw query keys (repeated keys are legal and preserved in order).
55        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        // Validate parameter keys before merging.
67        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        // Fail closed on any parameter key colliding with a raw query key.
78        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    /// Render the endpoint URI deterministically: `scheme:path`, then the raw query
93    /// byte-for-byte (if any), then the parameter entries in `BTreeMap` sorted order.
94    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    /// Render the endpoint URI with every option value resolved against `catalog`
110    /// and masked as `***` unless the catalog affirmatively resolves the key to a
111    /// non-secret [`UriOption`].
112    ///
113    /// The option set is built from BOTH the raw query pairs and the `params` map,
114    /// in the same rendering order and encoding as [`Self::to_canonical_string`]:
115    /// raw query values are replayed verbatim (after redaction) while `params`
116    /// values are percent-encoded. Fails safe: an unregistered scheme, an
117    /// unresolved key, and a secret option all render as `***`.
118    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    /// Iterate every renderable (key, value) pair in rendering order: raw-query
137    /// pairs first (in their original order), then `params` entries in
138    /// `BTreeMap` sorted order.
139    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            // Construction validates param keys; guard against post-construction
159            // mutation corrupting the canonical rendering (debug builds only).
160            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
187/// Debug helper that renders an [`EndpointUri`]'s parameter map with every value
188/// masked as `***` (keys stay visible). The private `raw_query` field is never
189/// rendered, so its unmasked bytes cannot leak through `Debug`.
190struct 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
202/// Append `value` percent-encoding exactly the reserved characters `& = % # ? +` (uppercase
203/// hex over their UTF-8 byte) and space (as `%20`); every other byte — including `:` and
204/// multi-byte UTF-8 — passes through verbatim.
205fn 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
220/// Resolve a URI option key to its catalog [`UriOption`] by exact name, then by
221/// alias — the two-phase resolution camel-lint performs (implemented locally
222/// because camel-api must not depend on camel-lint).
223fn 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
234/// Decide how to render a single option value: the original value when the
235/// catalog resolves `key` to a non-secret [`UriOption`], otherwise `***`
236/// (fail-safe: unknown scheme, unknown key, and secret options all mask).
237fn 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
244/// Mask any RFC 3986 userinfo in `path`: when the path begins with `//`, the
245/// authority runs to the first subsequent `/`; if it contains `@`, the userinfo
246/// (everything before that `@`) is replaced with `***`, keeping the `@` and the
247/// host. Every other path is returned unchanged. Applied in `Debug` and the
248/// redacted rendering so credentials cannot leak; the canonical rendering stays
249/// byte-faithful and does not call this.
250fn 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
267/// One renderable key/value pair in rendering order.
268struct RenderPair<'a> {
269    key: &'a str,
270    /// `None` for a raw-query flag (a pair with no `=`).
271    value: Option<&'a str>,
272    /// True when the pair came from the raw query (value replayed verbatim)
273    /// rather than the `params` map (value percent-encoded).
274    from_raw_query: bool,
275}
276
277/// Render one pair to `out` as `key` or `key=value`. Raw-query values are
278/// replayed verbatim; `params` values are percent-encoded. `transform` maps the
279/// value first — identity for the canonical rendering, redaction for the
280/// redacted rendering.
281fn 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        // No `:` anywhere — the scheme is absent.
350        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        // `:` first — the scheme is empty.
355        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        // Query pair `=1` has an empty key.
360        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        // Parameter key containing a reserved character.
365        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    // -----------------------------------------------------------------------
412    // Redaction tests — TDD: written before implementation
413    // -----------------------------------------------------------------------
414
415    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    /// `http`: `password` is secret, `timeout` is not; `token` is non-secret
434    /// with alias `apikey`; `cfg` is a non-secret prefix-pattern anchor. Every
435    /// other scheme is unregistered (so resolution fails safe and masks).
436    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}