use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[must_use]
pub fn redaction_marker(length: usize) -> String {
const WORD: &str = "REDACTED";
const MINIMUM: usize = WORD.len() + 2;
if length < MINIMUM {
return "*".repeat(length);
}
let padding = length - MINIMUM;
let left = padding.div_ceil(2);
let right = padding / 2;
format!("[{}{WORD}{}]", " ".repeat(left), " ".repeat(right))
}
#[derive(Clone, Default, Eq, Hash, PartialEq)]
pub struct Redacted<T = String>(T);
impl<T> Redacted<T> {
pub const fn new(secret: T) -> Self {
Self(secret)
}
pub const fn expose(&self) -> &T {
&self.0
}
pub fn into_inner(self) -> T {
self.0
}
}
impl<T: AsRef<str>> Redacted<T> {
#[must_use]
pub fn marker(&self) -> String {
redaction_marker(self.0.as_ref().chars().count())
}
}
impl<T: AsRef<str>> fmt::Debug for Redacted<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.marker())
}
}
impl<T: AsRef<str>> fmt::Display for Redacted<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.marker())
}
}
impl<T: AsRef<str>> Serialize for Redacted<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.marker())
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Redacted<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
T::deserialize(deserializer).map(Self)
}
}
pub fn serialize_exposed<S, T>(secret: &Redacted<T>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
T: Serialize,
{
secret.0.serialize(serializer)
}
impl<T> From<T> for Redacted<T> {
fn from(secret: T) -> Self {
Self(secret)
}
}
#[cfg(test)]
mod tests {
use super::{Redacted, redaction_marker};
#[test]
fn marker_matches_the_length_it_replaces() {
assert_eq!(redaction_marker(13), "[ REDACTED ]");
assert_eq!(redaction_marker(10), "[REDACTED]");
assert_eq!(redaction_marker(20), "[ REDACTED ]");
for length in 0..64 {
assert_eq!(
redaction_marker(length).chars().count(),
length,
"marker for {length} is the wrong width"
);
}
}
#[test]
fn short_values_degrade_to_asterisks() {
assert_eq!(redaction_marker(0), "");
assert_eq!(redaction_marker(9), "*********");
assert!(!redaction_marker(9).contains("REDACT"));
}
#[test]
fn no_rendering_path_reveals_the_secret() {
let secret = Redacted::new("sk-live-abcdef0123456789".to_owned());
assert!(!format!("{secret}").contains("sk-live"));
assert!(!format!("{secret:?}").contains("sk-live"));
assert!(
!serde_json::to_string(&secret)
.expect("redacted serializes")
.contains("sk-live")
);
assert!(
!serde_json::to_string(&vec![&secret, &secret])
.expect("nested redacted serializes")
.contains("sk-live")
);
assert_eq!(secret.expose(), "sk-live-abcdef0123456789");
assert_eq!(secret.marker().chars().count(), 24);
}
#[test]
fn deserialize_wraps_without_rendering() {
let secret: Redacted<String> =
serde_json::from_str("\"sk-live-abcdef0123456789\"").expect("redacted deserializes");
assert_eq!(secret.expose(), "sk-live-abcdef0123456789");
}
#[test]
fn length_is_measured_in_characters() {
let secret = Redacted::new("señor-señor-señor".to_owned());
assert_eq!(secret.expose().len(), 20);
assert_eq!(secret.marker().chars().count(), 17);
}
}