#[non_exhaustive]
#[derive(Clone)]
pub enum Credentials {
Basic {
username: String,
password: String,
},
Digest {
username: String,
password: String,
},
Bearer {
token: String,
},
}
impl PartialEq for Credentials {
fn eq(&self, other: &Self) -> bool {
use subtle::ConstantTimeEq;
let ct =
|a: &str, b: &str| a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes()));
match (self, other) {
(
Credentials::Basic {
username: ua,
password: pa,
},
Credentials::Basic {
username: ub,
password: pb,
},
)
| (
Credentials::Digest {
username: ua,
password: pa,
},
Credentials::Digest {
username: ub,
password: pb,
},
) => ua == ub && ct(pa, pb),
(Credentials::Bearer { token: a }, Credentials::Bearer { token: b }) => ct(a, b),
_ => false,
}
}
}
impl Eq for Credentials {}
impl Drop for Credentials {
fn drop(&mut self) {
use zeroize::Zeroize;
match self {
Credentials::Basic { password, .. } | Credentials::Digest { password, .. } => {
password.zeroize();
}
Credentials::Bearer { token } => token.zeroize(),
}
}
}
impl Credentials {
pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
Credentials::Digest {
username: username.into(),
password: password.into(),
}
}
pub fn bearer(token: impl Into<String>) -> Self {
Credentials::Bearer {
token: token.into(),
}
}
pub(crate) fn username_password(&self) -> Option<(&str, &str)> {
match self {
Credentials::Basic { username, password }
| Credentials::Digest { username, password } => Some((username, password)),
Credentials::Bearer { .. } => None,
}
}
}
impl core::fmt::Debug for Credentials {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Credentials::Basic { username, .. } => f
.debug_struct("Credentials::Basic")
.field("username", username)
.field("password", &"***")
.finish(),
Credentials::Digest { username, .. } => f
.debug_struct("Credentials::Digest")
.field("username", username)
.field("password", &"***")
.finish(),
Credentials::Bearer { .. } => f
.debug_struct("Credentials::Bearer")
.field("token", &"***")
.finish(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equality_is_exact_across_secret_username_and_variant() {
let base = Credentials::new("admin", "s3cr3t-password");
assert_eq!(base, Credentials::new("admin", "s3cr3t-password"));
assert_ne!(base, Credentials::new("admin", "s3cr3t-passworD"));
assert_ne!(base, Credentials::new("admin", "s3cr3t-passwor"));
assert_ne!(base, Credentials::new("admin2", "s3cr3t-password"));
assert_ne!(
base,
Credentials::Basic {
username: "admin".into(),
password: "s3cr3t-password".into(),
}
);
assert_eq!(Credentials::bearer("tok"), Credentials::bearer("tok"));
assert_ne!(Credentials::bearer("tok"), Credentials::bearer("tol"));
assert_ne!(Credentials::bearer("tok"), base);
}
#[test]
fn basic_debug_redacts_password_but_keeps_username() {
let creds = Credentials::Basic {
username: "admin".to_string(),
password: "s3cr3t-password".to_string(),
};
let debug = format!("{creds:?}");
assert!(!debug.contains("s3cr3t-password"), "leaked: {debug}");
assert!(
debug.contains("admin"),
"username should be visible: {debug}"
);
assert!(debug.contains("***"), "expected redaction marker: {debug}");
}
#[test]
fn digest_debug_redacts_password_but_keeps_username() {
let creds = Credentials::Digest {
username: "camera-user".to_string(),
password: "hunter2-super-secret".to_string(),
};
let debug = format!("{creds:?}");
assert!(!debug.contains("hunter2-super-secret"), "leaked: {debug}");
assert!(
debug.contains("camera-user"),
"username should be visible: {debug}"
);
assert!(debug.contains("***"), "expected redaction marker: {debug}");
}
#[test]
fn bearer_debug_redacts_token() {
let creds = Credentials::bearer("super-secret-bearer-token-xyz");
let debug = format!("{creds:?}");
assert!(
!debug.contains("super-secret-bearer-token-xyz"),
"leaked: {debug}"
);
assert!(debug.contains("***"), "expected redaction marker: {debug}");
}
}