use std::borrow::Cow;
use std::fmt;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use secrecy::{ExposeSecret, SecretBox};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub struct RedactedString(SecretBox<String>);
impl RedactedString {
pub fn new(value: impl Into<String>) -> Self {
Self(SecretBox::new(Box::new(value.into())))
}
pub fn expose_secret(&self) -> &str {
self.0.expose_secret().as_str()
}
pub fn is_empty(&self) -> bool {
self.expose_secret().is_empty()
}
pub fn preview(&self) -> String {
let chars: Vec<char> = self.expose_secret().chars().collect();
if chars.len() < 12 {
return "***".into();
}
let head: String = chars.iter().take(4).collect();
let tail: String = chars.iter().skip(chars.len() - 4).collect();
format!("{head}***{tail}")
}
}
impl Clone for RedactedString {
fn clone(&self) -> Self {
Self::new(self.expose_secret().to_owned())
}
}
impl PartialEq for RedactedString {
fn eq(&self, other: &Self) -> bool {
self.expose_secret() == other.expose_secret()
}
}
impl Eq for RedactedString {}
impl fmt::Debug for RedactedString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("RedactedString(***)")
}
}
impl fmt::Display for RedactedString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("***")
}
}
impl From<String> for RedactedString {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for RedactedString {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl Serialize for RedactedString {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.expose_secret())
}
}
impl<'de> Deserialize<'de> for RedactedString {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
Ok(Self::new(value))
}
}
impl JsonSchema for RedactedString {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("String")
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
String::json_schema(generator)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_redacts() {
let s = RedactedString::new("sk-secret");
assert_eq!(format!("{s:?}"), "RedactedString(***)");
}
#[test]
fn display_redacts() {
let s = RedactedString::new("sk-secret");
assert_eq!(format!("{s}"), "***");
}
#[test]
fn debug_in_option_redacts() {
let s = Some(RedactedString::new("sk-secret"));
let formatted = format!("{s:?}");
assert!(!formatted.contains("sk-secret"), "leaked: {formatted}");
}
#[test]
fn clone_preserves_value() {
let original = RedactedString::new("sk-secret");
let copied = original.clone();
assert_eq!(copied.expose_secret(), "sk-secret");
assert_eq!(original.expose_secret(), "sk-secret");
}
#[test]
fn serde_roundtrip_preserves_value() {
let s = RedactedString::new("sk-secret");
let encoded = serde_json::to_string(&s).unwrap();
assert_eq!(encoded, "\"sk-secret\"");
let decoded: RedactedString = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.expose_secret(), "sk-secret");
}
#[test]
fn json_schema_is_plain_string() {
let schema = schemars::schema_for!(RedactedString);
let value = serde_json::to_value(&schema).unwrap();
assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("string"));
}
#[test]
fn preview_keeps_first_and_last_four_chars() {
let s = RedactedString::new("sk-abcd1234567890wxyz");
assert_eq!(s.preview(), "sk-a***wxyz");
}
#[test]
fn preview_masks_short_values_completely() {
for short in ["", "abc", "sk-12345", "12345678901"] {
let s = RedactedString::new(short);
assert_eq!(
s.preview(),
"***",
"values shorter than 12 chars must render as `***`, got input {short:?}"
);
}
}
#[test]
fn preview_does_not_panic_on_multibyte_utf8() {
let s = RedactedString::new("αβγδ-中文-emoji-🔑🔑🔑🔑");
let preview = s.preview();
assert!(preview.contains("***"));
assert!(!preview.contains("中文"));
}
}