Skip to main content

rskit_util/
secret.rs

1//! Secret string type that masks its value in display, debug, and serialization.
2
3use std::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6use subtle::ConstantTimeEq;
7
8const SECRET_MASK: &str = "***";
9
10/// A string wrapper that prevents accidental secret exposure in logs or config dumps.
11///
12/// Use [`SecretString::expose`] to access the plaintext intentionally.
13#[derive(Clone, Default, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
14pub struct SecretString {
15    value: String,
16}
17
18impl SecretString {
19    /// Create a new secret from a plaintext value.
20    pub fn new(value: impl Into<String>) -> Self {
21        Self {
22            value: value.into(),
23        }
24    }
25
26    /// Return the plaintext value.
27    pub fn expose(&self) -> &str {
28        &self.value
29    }
30
31    /// Return `true` if the underlying value is empty.
32    pub const fn is_empty(&self) -> bool {
33        self.value.is_empty()
34    }
35
36    /// Return the length of the underlying value.
37    pub const fn len(&self) -> usize {
38        self.value.len()
39    }
40}
41
42impl PartialEq for SecretString {
43    fn eq(&self, other: &Self) -> bool {
44        self.value.as_bytes().ct_eq(other.value.as_bytes()).into()
45    }
46}
47
48impl Eq for SecretString {}
49
50impl fmt::Display for SecretString {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        if self.value.is_empty() {
53            Ok(())
54        } else {
55            f.write_str(SECRET_MASK)
56        }
57    }
58}
59
60impl fmt::Debug for SecretString {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str("SecretString(***)")
63    }
64}
65
66impl Serialize for SecretString {
67    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
68        if self.value.is_empty() {
69            ser.serialize_str("")
70        } else {
71            ser.serialize_str(SECRET_MASK)
72        }
73    }
74}
75
76impl<'de> Deserialize<'de> for SecretString {
77    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
78        let s = String::deserialize(de)?;
79        Ok(Self::new(s))
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn display_masks_value() {
89        let s = SecretString::new("password123");
90        assert_eq!(format!("{s}"), "***");
91    }
92
93    #[test]
94    fn display_empty_is_empty() {
95        let s = SecretString::new("");
96        assert_eq!(format!("{s}"), "");
97    }
98
99    #[test]
100    fn debug_masks_value() {
101        let s = SecretString::new("password123");
102        assert_eq!(format!("{s:?}"), "SecretString(***)");
103    }
104
105    #[test]
106    fn expose_returns_plaintext() {
107        let s = SecretString::new("hunter2");
108        assert_eq!(s.expose(), "hunter2");
109    }
110
111    #[test]
112    fn json_serializes_masked() {
113        let s = SecretString::new("secret");
114        let json = serde_json::to_string(&s).unwrap();
115        assert_eq!(json, r#""***""#);
116    }
117
118    #[test]
119    fn json_deserializes_plaintext() {
120        let s: SecretString = serde_json::from_str(r#""actual_value""#).unwrap();
121        assert_eq!(s.expose(), "actual_value");
122    }
123
124    #[test]
125    fn equality_is_constant_time() {
126        let a = SecretString::new("same-value");
127        let b = SecretString::new("same-value");
128        let c = SecretString::new("different");
129        assert_eq!(a, b);
130        assert_ne!(a, c);
131    }
132}