use std::fmt;
#[derive(Clone, PartialEq, Eq)]
pub struct SecretString(String);
impl SecretString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose_secret(&self) -> &str {
&self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SecretString([REDACTED; len={}])", self.0.len())
}
}
impl fmt::Display for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}
impl From<String> for SecretString {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for SecretString {
fn from(value: &str) -> Self {
Self::new(value.to_string())
}
}
impl serde::Serialize for SecretString {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str("[REDACTED]")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_format_never_includes_inner_value() {
let s = SecretString::new("sk-ant-api03-very-real-looking-but-not");
let dbg = format!("{:?}", s);
assert!(
!dbg.contains("sk-ant-api03"),
"Debug must NOT include the inner value: {dbg}"
);
assert!(dbg.contains("REDACTED"));
assert!(dbg.contains("len=38"));
}
#[test]
fn display_emits_redaction_marker() {
let s = SecretString::new("hunter2");
assert_eq!(format!("{}", s), "[REDACTED]");
assert!(!format!("{}", s).contains("hunter2"));
}
#[test]
fn serialize_emits_redaction_marker() {
let s = SecretString::new("sk-ant-api03-leaky-1234567890");
let json = serde_json::to_string(&s).expect("serialize");
assert_eq!(json, "\"[REDACTED]\"");
assert!(!json.contains("sk-ant"));
}
#[test]
fn expose_secret_returns_cleartext() {
let s = SecretString::new("real-value");
assert_eq!(s.expose_secret(), "real-value");
}
#[test]
fn empty_and_len_helpers() {
assert!(SecretString::new("").is_empty());
let s = SecretString::new("abcd");
assert!(!s.is_empty());
assert_eq!(s.len(), 4);
}
#[test]
fn parent_struct_derive_debug_does_not_leak() {
#[derive(Debug)]
struct Provider {
api_key: SecretString,
model: String,
}
let p = Provider {
api_key: SecretString::new("sk-ant-api03-DEFINITELY-LEAKED-IF-BROKEN"),
model: "claude-opus".into(),
};
let dbg = format!("{:?}", p);
assert!(
!dbg.contains("DEFINITELY-LEAKED-IF-BROKEN"),
"parent struct's derived Debug must NOT leak nested SecretString: {dbg}"
);
assert!(dbg.contains("REDACTED"));
assert!(
dbg.contains("claude-opus"),
"non-secret fields should print"
);
}
}