use secrecy::zeroize::Zeroize;
use secrecy::{CloneableSecret, ExposeSecret, SecretBox, SecretString, SerializableSecret};
use serde::{Deserialize, Serialize};
fn deserialize_string_or_int<'de, D>(d: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = String;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a string or integer")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<String, E> {
Ok(v.to_owned())
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<String, E> {
Ok(v)
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<String, E> {
Ok(v.to_string())
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<String, E> {
Ok(v.to_string())
}
}
d.deserialize_any(V)
}
macro_rules! sensitive_string_newtype {
($(#[$meta:meta])* $vis:vis $name:ident, $inner:ident) => {
#[derive(Clone, Serialize, Deserialize)]
#[serde(transparent)]
struct $inner(String);
sensitive_string_newtype!(@common $(#[$meta])* $vis $name, $inner);
};
($(#[$meta:meta])* $vis:vis $name:ident, $inner:ident, deserialize_with = $de:path) => {
#[derive(Clone, Serialize)]
#[serde(transparent)]
struct $inner(String);
impl<'de> Deserialize<'de> for $inner {
fn deserialize<D>(d: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
$de(d).map($inner)
}
}
sensitive_string_newtype!(@common $(#[$meta])* $vis $name, $inner);
};
(@common $(#[$meta:meta])* $vis:vis $name:ident, $inner:ident) => {
impl Zeroize for $inner {
fn zeroize(&mut self) {
self.0.zeroize();
}
}
impl CloneableSecret for $inner {}
impl SerializableSecret for $inner {}
$(#[$meta])*
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
$vis struct $name(SecretBox<$inner>);
impl $name {
pub fn new(value: impl Into<String>) -> Self {
Self(SecretBox::new(Box::new($inner(value.into()))))
}
pub fn expose_secret(&self) -> &str {
&self.0.expose_secret().0
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<SecretString> for $name {
fn from(value: SecretString) -> Self {
Self::new(value.expose_secret())
}
}
};
}
sensitive_string_newtype! {
pub AuthToken, AuthTokenInner
}
sensitive_string_newtype! {
pub CustomerId, CustomerIdInner
}
sensitive_string_newtype! {
pub AccountNumber, AccountNumberInner, deserialize_with = deserialize_string_or_int
}
impl PartialEq for AccountNumber {
fn eq(&self, other: &Self) -> bool {
self.expose_secret() == other.expose_secret()
}
}
impl Eq for AccountNumber {}
impl std::hash::Hash for AccountNumber {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.expose_secret().hash(state);
}
}
sensitive_string_newtype! {
pub AccountHash, AccountHashInner
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_token_debug_is_redacted() {
let token = AuthToken::new("super-secret-bearer");
let debug = format!("{token:?}");
assert!(
!debug.contains("super-secret-bearer"),
"Debug leaked secret: {debug}"
);
assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
}
#[test]
fn auth_token_serializes_to_inner_string() {
let token = AuthToken::new("abc123");
let json = serde_json::to_string(&token).unwrap();
assert_eq!(json, r#""abc123""#);
}
#[test]
fn auth_token_round_trips_through_serde() {
let token = AuthToken::new("round-trip");
let json = serde_json::to_string(&token).unwrap();
let restored: AuthToken = serde_json::from_str(&json).unwrap();
assert_eq!(restored.expose_secret(), "round-trip");
}
#[test]
fn customer_id_debug_is_redacted() {
let id = CustomerId::new("CUST-001");
let debug = format!("{id:?}");
assert!(!debug.contains("CUST-001"));
assert!(debug.contains("REDACTED"));
}
#[test]
fn customer_id_round_trips() {
let id = CustomerId::new("CUST-001");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, r#""CUST-001""#);
let restored: CustomerId = serde_json::from_str(&json).unwrap();
assert_eq!(restored.expose_secret(), "CUST-001");
}
#[test]
fn account_number_debug_is_redacted() {
let acct = AccountNumber::new("12345678");
let debug = format!("{acct:?}");
assert!(!debug.contains("12345678"));
assert!(debug.contains("REDACTED"));
}
#[test]
fn account_number_round_trips() {
let acct = AccountNumber::new("12345678");
let json = serde_json::to_string(&acct).unwrap();
assert_eq!(json, r#""12345678""#);
let restored: AccountNumber = serde_json::from_str(&json).unwrap();
assert_eq!(restored.expose_secret(), "12345678");
}
#[test]
fn account_number_deserializes_from_string_or_int() {
let from_str: AccountNumber = serde_json::from_str(r#""12345678""#).unwrap();
let from_int: AccountNumber = serde_json::from_str("12345678").unwrap();
assert_eq!(from_str.expose_secret(), "12345678");
assert_eq!(from_int.expose_secret(), "12345678");
assert_eq!(from_str, from_int);
let debug = format!("{from_int:?}");
assert!(!debug.contains("12345678"), "Debug leaked: {debug}");
assert!(debug.contains("REDACTED"), "expected REDACTED in {debug}");
}
#[test]
fn account_number_unexpected_type_produces_descriptive_error() {
let err = serde_json::from_str::<AccountNumber>("true").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("string") && msg.contains("integer"),
"missing expectation: {msg}",
);
assert!(msg.contains("bool"), "missing offending type: {msg}");
}
#[test]
fn account_hash_debug_is_redacted() {
let hash = AccountHash::new("ABCDEF0123456789");
let debug = format!("{hash:?}");
assert!(!debug.contains("ABCDEF0123456789"));
assert!(debug.contains("REDACTED"));
}
#[test]
fn account_hash_round_trips() {
let hash = AccountHash::new("ABCDEF0123456789");
let json = serde_json::to_string(&hash).unwrap();
assert_eq!(json, r#""ABCDEF0123456789""#);
let restored: AccountHash = serde_json::from_str(&json).unwrap();
assert_eq!(restored.expose_secret(), "ABCDEF0123456789");
}
}