use secrecy::{ExposeSecret, SecretString};
use std::sync::Arc;
use std::time::Instant;
#[allow(missing_docs)]
#[derive(thiserror::Error, Debug, Clone)]
pub enum SaslCredentialError {
#[error("Username is invalid: {reason}")]
InvalidUsername { reason: &'static str },
#[error("Password is invalid: {reason}")]
InvalidPassword { reason: &'static str },
}
#[derive(Clone, Debug)]
pub struct SaslCredential {
username: Arc<SecretString>,
password: Arc<SecretString>,
expires_on: Option<Instant>,
}
impl SaslCredential {
pub fn new(
username: impl Into<SecretString>,
password: impl Into<SecretString>,
) -> Result<Self, SaslCredentialError> {
let username: SecretString = username.into();
if username.expose_secret().is_empty() {
return Err(SaslCredentialError::InvalidUsername {
reason: "Username cannot be empty",
});
}
let password: SecretString = password.into();
if password.expose_secret().is_empty() {
return Err(SaslCredentialError::InvalidPassword {
reason: "Password cannot be empty",
});
}
Ok(Self {
username: Arc::new(username),
password: Arc::new(password),
expires_on: None,
})
}
#[must_use]
pub const fn with_expiry(mut self, expires_on: Instant) -> Self {
self.expires_on = Some(expires_on);
self
}
#[must_use]
pub fn expose_username(&self) -> &str {
self.username.expose_secret()
}
#[must_use]
pub fn expose_password(&self) -> &str {
self.password.expose_secret()
}
#[must_use]
pub const fn expires_on(&self) -> Option<Instant> {
self.expires_on
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn creates_non_empty_credential() {
let credential = SaslCredential::new("user", "password").expect("credential is valid");
assert_eq!(credential.expose_username(), "user");
assert_eq!(credential.expose_password(), "password");
}
#[test]
fn rejects_empty_values() {
assert!(matches!(
SaslCredential::new("", "password"),
Err(SaslCredentialError::InvalidUsername { .. })
));
assert!(matches!(
SaslCredential::new("user", ""),
Err(SaslCredentialError::InvalidPassword { .. })
));
}
#[test]
fn debug_output_redacts_values() {
let credential =
SaslCredential::new("sasl-user", "sasl-password").expect("credential is valid");
let debug = format!("{credential:?}");
assert!(!debug.contains("sasl-user"));
assert!(!debug.contains("sasl-password"));
}
#[test]
fn preserves_expiry() {
let expires_on = Instant::now() + Duration::from_secs(60);
let credential = SaslCredential::new("user", "password")
.expect("credential is valid")
.with_expiry(expires_on);
assert_eq!(credential.expires_on(), Some(expires_on));
}
}