toolkit_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 #[must_use]
34 pub fn into_inner(mut self) -> String {
35 std::mem::take(&mut self.0)
36 }
37}
38
39#[cfg(feature = "serde")]
40impl<'de> serde::Deserialize<'de> for SecretString {
41 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42 where
43 D: serde::Deserializer<'de>,
44 {
45 <String as serde::Deserialize>::deserialize(deserializer).map(SecretString::new)
46 }
47}
48
49#[cfg(feature = "serde")]
74pub fn serialize_option_exposed<S>(
75 value: &Option<SecretString>,
76 serializer: S,
77) -> Result<S::Ok, S::Error>
78where
79 S: serde::Serializer,
80{
81 match value {
82 Some(secret) => serializer.serialize_some(secret.expose()),
83 None => serializer.serialize_none(),
84 }
85}
86
87impl Clone for SecretString {
88 fn clone(&self) -> Self {
89 Self(self.0.clone())
90 }
91}
92
93impl fmt::Debug for SecretString {
94 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95 f.write_str("[REDACTED]")
96 }
97}
98
99impl fmt::Display for SecretString {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 f.write_str("[REDACTED]")
102 }
103}
104
105#[cfg(test)]
106#[cfg_attr(coverage_nightly, coverage(off))]
107mod tests {
108 use super::*;
109 use zeroize::Zeroize;
110
111 #[test]
112 fn debug_is_redacted() {
113 let s = SecretString::new("hunter2");
114 assert_eq!(format!("{s:?}"), "[REDACTED]");
115 }
116
117 #[test]
118 fn display_is_redacted() {
119 let s = SecretString::new("hunter2");
120 assert_eq!(format!("{s}"), "[REDACTED]");
121 }
122
123 #[test]
124 fn debug_does_not_contain_secret() {
125 let secret = "super-secret-value-12345";
126 let s = SecretString::new(secret);
127 let dbg = format!("{s:?}");
128 assert!(!dbg.contains(secret), "Debug must not contain the secret");
129 }
130
131 #[test]
132 fn expose_returns_original_value() {
133 let s = SecretString::new("hunter2");
134 assert_eq!(s.expose(), "hunter2");
135 }
136
137 #[test]
138 fn clone_preserves_value() {
139 let s = SecretString::new("value");
140 #[allow(clippy::redundant_clone)]
141 let c = s.clone();
142 assert_eq!(c.expose(), "value");
143 }
144
145 #[cfg(feature = "serde")]
146 #[test]
147 fn deserialize_from_json_string() {
148 let s: SecretString = serde_json::from_str("\"hunter2\"").unwrap();
149 assert_eq!(s.expose(), "hunter2");
150 }
151
152 #[test]
153 fn zeroize_clears_buffer() {
154 let mut s = SecretString::new("sensitive");
155 assert_eq!(s.expose(), "sensitive");
156
157 s.zeroize();
158 assert!(s.0.is_empty(), "buffer should be empty after zeroize");
159 }
160}