use std::borrow::Cow;
use fe2o3_amqp::sasl_profile::SaslProfile;
use percent_encoding::percent_decode_str;
use url::Url;
use crate::error::AmqpError;
#[derive(Debug, Clone)]
#[must_use]
pub struct Sasl {
pub(crate) profile: SaslProfile,
}
impl Sasl {
pub fn anonymous() -> Self {
Self {
profile: SaslProfile::Anonymous,
}
}
pub fn plain(username: impl Into<String>, password: impl Into<String>) -> Self {
Self {
profile: SaslProfile::Plain {
username: username.into(),
password: password.into(),
},
}
}
pub fn external() -> Self {
Self {
profile: SaslProfile::External,
}
}
}
pub(crate) fn sasl_profile(
explicit: Option<&Sasl>,
url: &Url,
) -> Result<Option<SaslProfile>, AmqpError> {
if let Some(sasl) = explicit {
return Ok(Some(sasl.profile.clone()));
}
let (username, Some(password)) = (url.username(), url.password()) else {
return Ok(None);
};
if username.is_empty() {
return Ok(None);
}
let decode = |part: &str| {
percent_decode_str(part)
.decode_utf8()
.map(Cow::into_owned)
.map_err(|_| {
AmqpError::Connect(Box::from(
"the URL's userinfo is not UTF-8 once percent-decoded",
))
})
};
Ok(Some(SaslProfile::Plain {
username: decode(username)?,
password: decode(password)?,
}))
}
#[cfg(test)]
mod tests {
use std::hash::{BuildHasher, RandomState};
use super::*;
fn profile(explicit: Option<&Sasl>, url: &str) -> Result<Option<SaslProfile>, AmqpError> {
sasl_profile(explicit, &Url::parse(url).expect("the URL parses"))
}
fn plain(profile: Option<SaslProfile>) -> Option<(String, String)> {
match profile {
Some(SaslProfile::Plain { username, password }) => Some((username, password)),
_ => None,
}
}
#[test]
fn an_explicit_profile_wins_over_the_userinfo() {
let password = format!("{:016x}", RandomState::new().hash_one("password"));
let explicit = Sasl::plain("svc", password.as_str());
let chosen = profile(Some(&explicit), "amqp://other:wrong@broker:5672").expect("valid");
assert_eq!(plain(chosen), Some(("svc".to_owned(), password)));
let chosen =
profile(Some(&Sasl::anonymous()), "amqp://other:wrong@broker:5672").expect("valid");
assert!(
matches!(chosen, Some(SaslProfile::Anonymous)),
"got {chosen:?}"
);
}
#[test]
fn the_userinfo_selects_plain_percent_decoded() {
let chosen = profile(None, "amqp://svc%2Ba:p%40ss%3Aw@broker:5672").expect("valid");
assert_eq!(
plain(chosen),
Some(("svc+a".to_owned(), "p@ss:w".to_owned()))
);
}
#[test]
fn a_url_without_a_user_and_a_password_selects_nothing() {
for url in [
"amqp://broker:5672",
"amqp://svc@broker:5672",
"amqp://:secret@broker:5672",
] {
let chosen = profile(None, url).expect("valid");
assert!(chosen.is_none(), "{url}: got {chosen:?}");
}
}
#[test]
fn userinfo_that_is_not_utf8_once_decoded_is_refused() {
for url in [
"amqp://%FF:secret@broker:5672",
"amqp://svc:%C3@broker:5672",
] {
let refused = profile(None, url).expect_err("no PLAIN credential is not UTF-8");
assert!(
matches!(refused, AmqpError::Connect(_)),
"{url}: got {refused}"
);
}
}
}