use std::fmt;
use serde::de::Deserializer;
use serde::ser::Serializer;
const REDACTED: &str = "***REDACTED***";
#[derive(Clone, Default, PartialEq, Eq)]
pub struct SensitiveString(String);
impl SensitiveString {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl serde::Serialize for SensitiveString {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(REDACTED)
}
}
impl<'de> serde::Deserialize<'de> for SensitiveString {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(SensitiveString)
}
}
impl fmt::Display for SensitiveString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{REDACTED}")
}
}
impl fmt::Debug for SensitiveString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SensitiveString({REDACTED})")
}
}
impl From<String> for SensitiveString {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SensitiveString {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_always_redacted() {
let s = SensitiveString::new("my_actual_secret");
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, format!("\"{REDACTED}\""));
assert!(!json.contains("my_actual_secret"));
}
#[test]
fn deserialize_reads_actual_value() {
let json = "\"my_actual_secret\"";
let s: SensitiveString = serde_json::from_str(json).unwrap();
assert_eq!(s.expose(), "my_actual_secret");
}
#[test]
fn display_is_redacted() {
let s = SensitiveString::new("secret123");
assert_eq!(format!("{s}"), REDACTED);
assert!(!format!("{s}").contains("secret123"));
}
#[test]
fn debug_is_redacted() {
let s = SensitiveString::new("secret123");
let debug = format!("{s:?}");
assert!(debug.contains(REDACTED));
assert!(!debug.contains("secret123"));
}
#[test]
fn expose_returns_actual_value() {
let s = SensitiveString::new("the_real_value");
assert_eq!(s.expose(), "the_real_value");
}
#[test]
fn default_is_empty() {
let s = SensitiveString::default();
assert!(s.is_empty());
assert_eq!(s.expose(), "");
}
#[test]
fn from_string() {
let s: SensitiveString = "hello".into();
assert_eq!(s.expose(), "hello");
let s: SensitiveString = String::from("world").into();
assert_eq!(s.expose(), "world");
}
#[test]
fn struct_with_sensitive_field_serialises_safely() {
#[derive(serde::Serialize, serde::Deserialize)]
struct Config {
host: String,
connection_string: SensitiveString,
}
let config = Config {
host: "db.example.com".into(),
connection_string: SensitiveString::new("postgres://user:pass@host/db"),
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("db.example.com"));
assert!(json.contains(REDACTED));
assert!(!json.contains("postgres://"));
assert!(!json.contains("user:pass"));
}
#[test]
fn struct_with_sensitive_field_deserialises_correctly() {
#[derive(serde::Serialize, serde::Deserialize)]
struct Config {
host: String,
connection_string: SensitiveString,
}
let json =
r#"{"host":"db.example.com","connection_string":"postgres://user:pass@host/db"}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.host, "db.example.com");
assert_eq!(
config.connection_string.expose(),
"postgres://user:pass@host/db"
);
}
#[test]
fn no_leak_through_any_serialisation_path() {
let secret = "super_secret_value_12345";
let s = SensitiveString::new(secret);
assert!(!serde_json::to_string(&s).unwrap().contains(secret));
assert!(!format!("{s}").contains(secret));
assert!(!format!("{s:?}").contains(secret));
assert_eq!(s.expose(), secret);
}
}