Skip to main content

modkit_utils/
secret_string.rs

1use std::fmt;
2
3use zeroize::{Zeroize, ZeroizeOnDrop};
4
5/// Opaque wrapper around a secret string value.
6///
7/// `Debug` and `Display` both print `[REDACTED]` — the inner value is never
8/// exposed through formatting traits.  Use [`expose`](Self::expose) for
9/// controlled access when constructing HTTP headers or form bodies.
10///
11/// On [`Drop`] the backing buffer is securely zeroed via the [`zeroize`] crate.
12#[derive(Zeroize, ZeroizeOnDrop)]
13pub struct SecretString(String);
14
15impl SecretString {
16    /// Create a new `SecretString` from a plain value.
17    pub fn new(value: impl Into<String>) -> Self {
18        Self(value.into())
19    }
20
21    /// Provide read-only access to the underlying secret.
22    ///
23    /// Callers must not log, store, or otherwise persist the returned slice.
24    #[must_use]
25    pub fn expose(&self) -> &str {
26        &self.0
27    }
28}
29
30#[cfg(feature = "serde")]
31impl<'de> serde::Deserialize<'de> for SecretString {
32    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
33    where
34        D: serde::Deserializer<'de>,
35    {
36        <String as serde::Deserialize>::deserialize(deserializer).map(SecretString::new)
37    }
38}
39
40impl Clone for SecretString {
41    fn clone(&self) -> Self {
42        Self(self.0.clone())
43    }
44}
45
46impl fmt::Debug for SecretString {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str("[REDACTED]")
49    }
50}
51
52impl fmt::Display for SecretString {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str("[REDACTED]")
55    }
56}
57
58#[cfg(test)]
59#[cfg_attr(coverage_nightly, coverage(off))]
60mod tests {
61    use super::*;
62    use zeroize::Zeroize;
63
64    #[test]
65    fn debug_is_redacted() {
66        let s = SecretString::new("hunter2");
67        assert_eq!(format!("{s:?}"), "[REDACTED]");
68    }
69
70    #[test]
71    fn display_is_redacted() {
72        let s = SecretString::new("hunter2");
73        assert_eq!(format!("{s}"), "[REDACTED]");
74    }
75
76    #[test]
77    fn debug_does_not_contain_secret() {
78        let secret = "super-secret-value-12345";
79        let s = SecretString::new(secret);
80        let dbg = format!("{s:?}");
81        assert!(!dbg.contains(secret), "Debug must not contain the secret");
82    }
83
84    #[test]
85    fn expose_returns_original_value() {
86        let s = SecretString::new("hunter2");
87        assert_eq!(s.expose(), "hunter2");
88    }
89
90    #[test]
91    fn clone_preserves_value() {
92        let s = SecretString::new("value");
93        #[allow(clippy::redundant_clone)]
94        let c = s.clone();
95        assert_eq!(c.expose(), "value");
96    }
97
98    #[cfg(feature = "serde")]
99    #[test]
100    fn deserialize_from_json_string() {
101        let s: SecretString = serde_json::from_str("\"hunter2\"").unwrap();
102        assert_eq!(s.expose(), "hunter2");
103    }
104
105    #[test]
106    fn zeroize_clears_buffer() {
107        let mut s = SecretString::new("sensitive");
108        assert_eq!(s.expose(), "sensitive");
109
110        s.zeroize();
111        assert!(s.0.is_empty(), "buffer should be empty after zeroize");
112    }
113}