Skip to main content

ruststream_amqp/
config.rs

1//! Connection configuration: SASL profiles.
2
3use std::borrow::Cow;
4
5use fe2o3_amqp::sasl_profile::SaslProfile;
6use percent_encoding::percent_decode_str;
7use url::Url;
8
9use crate::error::AmqpError;
10
11/// How the broker authenticates the connection, mapped onto the client's SASL profiles.
12///
13/// Constructed with the per-mechanism constructors and passed to
14/// [`AmqpBroker::sasl`](crate::AmqpBroker::sasl). A URL of the form `amqp://user:pass@host` also
15/// selects PLAIN implicitly; an explicit profile set here wins.
16///
17/// # Examples
18///
19/// ```
20/// # mod demo {
21/// use ruststream_amqp::prelude::*;
22/// # use serde::Deserialize;
23/// #
24/// # #[derive(Deserialize)]
25/// # struct Order {
26/// #     id: u64,
27/// # }
28/// #
29/// # #[subscriber(AmqpAddress::queue("orders"))]
30/// # async fn handle(order: &Order) -> HandlerOutcome {
31/// #     tracing::info!(order.id, "accepted");
32/// #     HandlerOutcome::ack()
33/// # }
34///
35/// #[ruststream::app]
36/// fn app() -> impl App {
37///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
38///         AmqpBroker::new("amqp://broker.internal:5672").sasl(Sasl::plain("svc", "secret")),
39///         |b| {
40///             b.include(handle);
41///         },
42///     )
43/// }
44/// # }
45/// # fn main() {}
46/// ```
47#[derive(Debug, Clone)]
48#[must_use]
49pub struct Sasl {
50    pub(crate) profile: SaslProfile,
51}
52
53impl Sasl {
54    /// SASL ANONYMOUS: no credentials, for brokers that allow unauthenticated connections.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// # mod demo {
60    /// use ruststream_amqp::prelude::*;
61    /// # use serde::Deserialize;
62    /// #
63    /// # #[derive(Deserialize)]
64    /// # struct Order {
65    /// #     id: u64,
66    /// # }
67    /// #
68    /// # #[subscriber(AmqpAddress::queue("orders"))]
69    /// # async fn handle(order: &Order) -> HandlerOutcome {
70    /// #     tracing::info!(order.id, "accepted");
71    /// #     HandlerOutcome::ack()
72    /// # }
73    ///
74    /// #[ruststream::app]
75    /// fn app() -> impl App {
76    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
77    ///         // The broker admits unauthenticated connections.
78    ///         AmqpBroker::new("amqp://broker.internal:5672").sasl(Sasl::anonymous()),
79    ///         |b| {
80    ///             b.include(handle);
81    ///         },
82    ///     )
83    /// }
84    /// # }
85    /// # fn main() {}
86    /// ```
87    pub fn anonymous() -> Self {
88        Self {
89            profile: SaslProfile::Anonymous,
90        }
91    }
92
93    /// SASL PLAIN: username and password.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// # mod demo {
99    /// use ruststream_amqp::prelude::*;
100    /// # use serde::Deserialize;
101    /// #
102    /// # #[derive(Deserialize)]
103    /// # struct Order {
104    /// #     id: u64,
105    /// # }
106    /// #
107    /// # #[subscriber(AmqpAddress::queue("orders"))]
108    /// # async fn handle(order: &Order) -> HandlerOutcome {
109    /// #     tracing::info!(order.id, "accepted");
110    /// #     HandlerOutcome::ack()
111    /// # }
112    ///
113    /// #[ruststream::app]
114    /// fn app() -> impl App {
115    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
116    ///         AmqpBroker::new("amqp://broker.internal:5672").sasl(Sasl::plain("svc", "secret")),
117    ///         |b| {
118    ///             b.include(handle);
119    ///         },
120    ///     )
121    /// }
122    /// # }
123    /// # fn main() {}
124    /// ```
125    pub fn plain(username: impl Into<String>, password: impl Into<String>) -> Self {
126        Self {
127            profile: SaslProfile::Plain {
128                username: username.into(),
129                password: password.into(),
130            },
131        }
132    }
133
134    /// SASL EXTERNAL: the broker takes an identity established outside SASL.
135    ///
136    /// The connection presents no client certificate: the crate exposes no TLS configuration,
137    /// and the client opens TLS without client authentication.
138    ///
139    /// # Examples
140    ///
141    /// ```
142    /// # mod demo {
143    /// use ruststream_amqp::prelude::*;
144    /// # use serde::Deserialize;
145    /// #
146    /// # #[derive(Deserialize)]
147    /// # struct Order {
148    /// #     id: u64,
149    /// # }
150    /// #
151    /// # #[subscriber(AmqpAddress::queue("orders"))]
152    /// # async fn handle(order: &Order) -> HandlerOutcome {
153    /// #     tracing::info!(order.id, "accepted");
154    /// #     HandlerOutcome::ack()
155    /// # }
156    ///
157    /// #[ruststream::app]
158    /// fn app() -> impl App {
159    ///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
160    ///         // The broker identifies the service outside SASL; no credentials travel here.
161    ///         AmqpBroker::new("amqps://broker.internal:5671").sasl(Sasl::external()),
162    ///         |b| {
163    ///             b.include(handle);
164    ///         },
165    ///     )
166    /// }
167    /// # }
168    /// # fn main() {}
169    /// ```
170    pub fn external() -> Self {
171        Self {
172            profile: SaslProfile::External,
173        }
174    }
175}
176
177/// The SASL profile a connection opens with: the one set with
178/// [`AmqpBroker::sasl`](crate::AmqpBroker::sasl), or else PLAIN from the URL's userinfo, or else
179/// none, which leaves the choice to the client.
180///
181/// A URL carries its userinfo percent-encoded, so a password with a reserved character
182/// (`p%40ss`) is decoded before it becomes a credential.
183///
184/// # Errors
185///
186/// Returns [`AmqpError::Connect`] when the decoded userinfo is not UTF-8, which a PLAIN credential
187/// has to be.
188pub(crate) fn sasl_profile(
189    explicit: Option<&Sasl>,
190    url: &Url,
191) -> Result<Option<SaslProfile>, AmqpError> {
192    if let Some(sasl) = explicit {
193        return Ok(Some(sasl.profile.clone()));
194    }
195    // The client's own reading of a URL: a user and a password select PLAIN, anything less selects
196    // nothing.
197    let (username, Some(password)) = (url.username(), url.password()) else {
198        return Ok(None);
199    };
200    if username.is_empty() {
201        return Ok(None);
202    }
203    let decode = |part: &str| {
204        percent_decode_str(part)
205            .decode_utf8()
206            .map(Cow::into_owned)
207            .map_err(|_| {
208                AmqpError::Connect(Box::from(
209                    "the URL's userinfo is not UTF-8 once percent-decoded",
210                ))
211            })
212    };
213    Ok(Some(SaslProfile::Plain {
214        username: decode(username)?,
215        password: decode(password)?,
216    }))
217}
218
219#[cfg(test)]
220mod tests {
221    use std::hash::{BuildHasher, RandomState};
222
223    use super::*;
224
225    fn profile(explicit: Option<&Sasl>, url: &str) -> Result<Option<SaslProfile>, AmqpError> {
226        sasl_profile(explicit, &Url::parse(url).expect("the URL parses"))
227    }
228
229    fn plain(profile: Option<SaslProfile>) -> Option<(String, String)> {
230        match profile {
231            Some(SaslProfile::Plain { username, password }) => Some((username, password)),
232            _ => None,
233        }
234    }
235
236    #[test]
237    fn an_explicit_profile_wins_over_the_userinfo() {
238        // Drawn afresh for every run, so no credential lives in the source.
239        let password = format!("{:016x}", RandomState::new().hash_one("password"));
240        let explicit = Sasl::plain("svc", password.as_str());
241        let chosen = profile(Some(&explicit), "amqp://other:wrong@broker:5672").expect("valid");
242        assert_eq!(plain(chosen), Some(("svc".to_owned(), password)));
243
244        let chosen =
245            profile(Some(&Sasl::anonymous()), "amqp://other:wrong@broker:5672").expect("valid");
246        assert!(
247            matches!(chosen, Some(SaslProfile::Anonymous)),
248            "got {chosen:?}"
249        );
250    }
251
252    #[test]
253    fn the_userinfo_selects_plain_percent_decoded() {
254        let chosen = profile(None, "amqp://svc%2Ba:p%40ss%3Aw@broker:5672").expect("valid");
255        assert_eq!(
256            plain(chosen),
257            Some(("svc+a".to_owned(), "p@ss:w".to_owned()))
258        );
259    }
260
261    #[test]
262    fn a_url_without_a_user_and_a_password_selects_nothing() {
263        for url in [
264            "amqp://broker:5672",
265            "amqp://svc@broker:5672",
266            "amqp://:secret@broker:5672",
267        ] {
268            let chosen = profile(None, url).expect("valid");
269            assert!(chosen.is_none(), "{url}: got {chosen:?}");
270        }
271    }
272
273    #[test]
274    fn userinfo_that_is_not_utf8_once_decoded_is_refused() {
275        for url in [
276            "amqp://%FF:secret@broker:5672",
277            "amqp://svc:%C3@broker:5672",
278        ] {
279            let refused = profile(None, url).expect_err("no PLAIN credential is not UTF-8");
280            assert!(
281                matches!(refused, AmqpError::Connect(_)),
282                "{url}: got {refused}"
283            );
284        }
285    }
286}