use std::hash::{Hash, Hasher};
use std::sync::OnceLock;
use zeroize::{Zeroize, Zeroizing};
#[derive(Clone)]
pub struct Redacted<T: Zeroize = String>(Zeroizing<T>);
impl<T: Zeroize> Redacted<T> {
pub fn new(value: T) -> Self {
Self(Zeroizing::new(value))
}
}
impl<T: Zeroize + AsRef<str>> Redacted<T> {
#[must_use]
pub fn expose_secret(&self) -> &str {
self.0.as_ref()
}
}
impl<T: Zeroize> std::fmt::Debug for Redacted<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Redacted(***)")
}
}
impl<T: Zeroize> std::fmt::Display for Redacted<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("***")
}
}
impl<T: Zeroize + PartialEq> PartialEq for Redacted<T> {
fn eq(&self, other: &Self) -> bool {
*self.0 == *other.0
}
}
impl<T: Zeroize + Eq> Eq for Redacted<T> {}
impl<T: Zeroize + std::hash::Hash> std::hash::Hash for Redacted<T> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
(*self.0).hash(state);
}
}
impl<T: Zeroize> zeroize::ZeroizeOnDrop for Redacted<T> {}
pub fn digest_salt() -> u64 {
static SALT: OnceLock<u64> = OnceLock::new();
*SALT.get_or_init(|| {
use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
let mut hasher = RandomState::new().build_hasher();
std::process::id().hash(&mut hasher);
std::time::SystemTime::now().hash(&mut hasher);
hasher.finish()
})
}
pub fn auth_digest(origin: &str, secret: Option<&str>) -> Option<u64> {
let secret = secret?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
digest_salt().hash(&mut hasher);
origin.hash(&mut hasher);
secret.hash(&mut hasher);
Some(hasher.finish())
}
#[cfg(test)]
mod tests {
use super::Redacted;
#[test]
fn debug_redacts() {
let secret = Redacted::new("hunter2".to_string());
assert_eq!(format!("{secret:?}"), "Redacted(***)");
}
#[test]
fn display_redacts() {
let secret = Redacted::new("hunter2".to_string());
assert_eq!(format!("{secret}"), "***");
}
#[test]
fn expose_secret_recovers_the_value() {
let secret = Redacted::new("hunter2".to_string());
assert_eq!(secret.expose_secret(), "hunter2");
}
#[test]
fn equality_compares_the_wrapped_value() {
assert_eq!(
Redacted::new("hunter2".to_string()),
Redacted::new("hunter2".to_string())
);
assert_ne!(
Redacted::new("hunter2".to_string()),
Redacted::new("other".to_string())
);
}
#[test]
fn embedding_in_a_debug_derive_still_redacts() {
#[derive(Debug)]
struct Wrapper {
token: Redacted,
}
let wrapper = Wrapper {
token: Redacted::new("hunter2".to_string()),
};
assert_eq!(wrapper.token.expose_secret(), "hunter2");
let debug_output = format!("{wrapper:?}");
assert!(debug_output.contains("Redacted(***)"), "{debug_output}");
assert!(!debug_output.contains("hunter2"), "{debug_output}");
}
#[test]
fn implements_zeroize_on_drop() {
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<Redacted<String>>();
}
#[test]
fn auth_digest_no_secret_is_none() {
assert_eq!(super::auth_digest("https://example.com", None), None);
}
#[test]
fn auth_digest_same_origin_and_secret_is_stable_within_process() {
let a = super::auth_digest("https://example.com", Some("token"));
let b = super::auth_digest("https://example.com", Some("token"));
assert_eq!(a, b);
assert!(a.is_some());
}
#[test]
fn auth_digest_different_origins_diverge() {
let a = super::auth_digest("https://example.com", Some("token")).unwrap();
let b = super::auth_digest("https://other.example.com", Some("token")).unwrap();
assert_ne!(a, b);
}
#[test]
fn auth_digest_different_secrets_diverge() {
let a = super::auth_digest("https://example.com", Some("token-a")).unwrap();
let b = super::auth_digest("https://example.com", Some("token-b")).unwrap();
assert_ne!(a, b);
}
}