use std::cell::Cell;
use std::fmt;
use serde::de::Deserializer;
use serde::ser::Serializer;
const REDACTED: &str = "***REDACTED***";
thread_local! {
static EXPOSE: Cell<bool> = const { Cell::new(false) };
}
struct ExposeGuard {
prev: bool,
}
impl ExposeGuard {
fn enter() -> Self {
EXPOSE.with(|e| {
let prev = e.get();
e.set(true);
Self { prev }
})
}
}
impl Drop for ExposeGuard {
fn drop(&mut self) {
let prev = self.prev;
EXPOSE.with(|e| e.set(prev));
}
}
pub fn expose_during<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
let _guard = ExposeGuard::enter();
f()
}
#[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> {
if EXPOSE.with(Cell::get) {
serializer.serialize_str(&self.0)
} else {
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);
}
#[test]
fn round_trip_inside_expose_during_preserves_value() {
let s = SensitiveString::new("hunter2");
let v = expose_during(|| serde_json::to_value(&s).unwrap());
let round_tripped: SensitiveString = serde_json::from_value(v).unwrap();
assert_eq!(round_tripped.expose(), "hunter2");
}
#[test]
fn round_trip_outside_expose_during_redacts() {
let s = SensitiveString::new("hunter2");
let v = serde_json::to_value(&s).unwrap();
let round_tripped: SensitiveString = serde_json::from_value(v).unwrap();
assert_eq!(round_tripped.expose(), REDACTED);
}
#[test]
fn expose_during_restores_after_body() {
let s = SensitiveString::new("secret");
assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
expose_during(|| {
assert!(serde_json::to_string(&s).unwrap().contains("secret"));
});
assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
assert!(!serde_json::to_string(&s).unwrap().contains("secret"));
}
#[test]
fn expose_during_restores_after_panic() {
let s = SensitiveString::new("secret");
let result = std::panic::catch_unwind(|| {
expose_during(|| {
assert!(serde_json::to_string(&s).unwrap().contains("secret"));
panic!("simulated panic");
})
});
assert!(result.is_err(), "panic should have propagated");
assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
assert!(!serde_json::to_string(&s).unwrap().contains("secret"));
}
#[test]
fn expose_during_nests_correctly() {
let s = SensitiveString::new("secret");
expose_during(|| {
assert!(serde_json::to_string(&s).unwrap().contains("secret"));
expose_during(|| {
assert!(serde_json::to_string(&s).unwrap().contains("secret"));
});
assert!(serde_json::to_string(&s).unwrap().contains("secret"));
});
assert!(serde_json::to_string(&s).unwrap().contains(REDACTED));
}
#[test]
fn struct_round_trip_inside_expose_during_preserves_values() {
#[derive(serde::Serialize, serde::Deserialize)]
struct Config {
host: String,
password: SensitiveString,
}
let original = Config {
host: "db.example.com".into(),
password: SensitiveString::new("env-resolved-secret"),
};
let round_tripped: Config = expose_during(|| {
let v = serde_json::to_value(&original).unwrap();
serde_json::from_value(v).unwrap()
});
assert_eq!(round_tripped.host, "db.example.com");
assert_eq!(round_tripped.password.expose(), "env-resolved-secret");
}
#[test]
fn expose_flag_is_thread_local() {
use std::sync::{Arc, Mutex};
let s = Arc::new(SensitiveString::new("secret"));
let observed = Arc::new(Mutex::new(String::new()));
let s2 = Arc::clone(&s);
let observed2 = Arc::clone(&observed);
let handle = std::thread::spawn(move || {
let out = serde_json::to_string(&*s2).unwrap();
*observed2.lock().unwrap() = out;
});
expose_during(|| {
std::thread::yield_now();
});
handle.join().unwrap();
let b_output = observed.lock().unwrap().clone();
assert!(
b_output.contains(REDACTED),
"thread B should have observed REDACTED, got: {b_output}"
);
}
}