modkit_utils/
secret_string.rs1use std::fmt;
2
3use zeroize::{Zeroize, ZeroizeOnDrop};
4
5#[derive(Zeroize, ZeroizeOnDrop)]
13pub struct SecretString(String);
14
15impl SecretString {
16 pub fn new(value: impl Into<String>) -> Self {
18 Self(value.into())
19 }
20
21 #[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}