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
30impl Clone for SecretString {
31    fn clone(&self) -> Self {
32        Self(self.0.clone())
33    }
34}
35
36impl fmt::Debug for SecretString {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str("[REDACTED]")
39    }
40}
41
42impl fmt::Display for SecretString {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str("[REDACTED]")
45    }
46}
47
48#[cfg(test)]
49#[cfg_attr(coverage_nightly, coverage(off))]
50mod tests {
51    use super::*;
52    use zeroize::Zeroize;
53
54    #[test]
55    fn debug_is_redacted() {
56        let s = SecretString::new("hunter2");
57        assert_eq!(format!("{s:?}"), "[REDACTED]");
58    }
59
60    #[test]
61    fn display_is_redacted() {
62        let s = SecretString::new("hunter2");
63        assert_eq!(format!("{s}"), "[REDACTED]");
64    }
65
66    #[test]
67    fn debug_does_not_contain_secret() {
68        let secret = "super-secret-value-12345";
69        let s = SecretString::new(secret);
70        let dbg = format!("{s:?}");
71        assert!(!dbg.contains(secret), "Debug must not contain the secret");
72    }
73
74    #[test]
75    fn expose_returns_original_value() {
76        let s = SecretString::new("hunter2");
77        assert_eq!(s.expose(), "hunter2");
78    }
79
80    #[test]
81    fn clone_preserves_value() {
82        let s = SecretString::new("value");
83        #[allow(clippy::redundant_clone)]
84        let c = s.clone();
85        assert_eq!(c.expose(), "value");
86    }
87
88    #[test]
89    fn zeroize_clears_buffer() {
90        let mut s = SecretString::new("sensitive");
91        assert_eq!(s.expose(), "sensitive");
92
93        s.zeroize();
94        assert!(s.0.is_empty(), "buffer should be empty after zeroize");
95    }
96}