#[derive(Clone, PartialEq, Eq)]
pub struct SecretString(String);
impl SecretString {
#[must_use]
pub fn new(value: String) -> Self {
Self(value)
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl core::fmt::Debug for SecretString {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("SecretString(<redacted>)")
}
}
impl core::fmt::Display for SecretString {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("<redacted>")
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for SecretString {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("<redacted>")
}
}
macro_rules! secret_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct $name(SecretString);
impl $name {
#[must_use]
pub fn new(value: String) -> Self {
Self(SecretString::new(value))
}
#[must_use]
pub fn expose(&self) -> &str {
self.0.expose()
}
#[must_use]
pub fn as_secret(&self) -> &SecretString {
&self.0
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for $name {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
};
}
secret_newtype! {
PlainCode
}
secret_newtype! {
SessionSecret
}
secret_newtype! {
FormTokenSecret
}
macro_rules! id_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct $name(String);
impl $name {
#[must_use]
pub fn new(value: String) -> Self {
Self(value)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl core::fmt::Display for $name {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
};
}
id_newtype! {
CodeId
}
id_newtype! {
SubjectId
}
id_newtype! {
SessionId
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_string_redacts_debug_and_display() {
let s = SecretString::new("hunter2".to_string());
assert_eq!(format!("{s:?}"), "SecretString(<redacted>)");
assert_eq!(format!("{s}"), "<redacted>");
assert!(!format!("{s:?}").contains("hunter2"));
assert!(!format!("{s}").contains("hunter2"));
assert_eq!(s.expose(), "hunter2");
}
#[test]
fn secret_newtypes_redact_debug() {
let c = PlainCode::new("ABCD2345".to_string());
let dbg = format!("{c:?}");
assert!(
!dbg.contains("ABCD2345"),
"PlainCode Debug leaked plaintext: {dbg}"
);
assert!(dbg.contains("<redacted>"));
assert_eq!(c.expose(), "ABCD2345");
}
#[test]
fn id_newtype_displays_and_roundtrips() {
let id = CodeId::new("abc123".to_string());
assert_eq!(id.as_str(), "abc123");
assert_eq!(format!("{id}"), "abc123");
assert_eq!(CodeId::from("x".to_string()).as_str(), "x");
}
#[cfg(feature = "serde")]
#[test]
fn secret_serializes_redacted() {
let c = SessionSecret::new("supersecret".to_string());
let json = serde_json::to_string(&c).unwrap();
assert_eq!(json, "\"<redacted>\"");
assert!(!json.contains("supersecret"));
}
}