Skip to main content

toolkit_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    /// Consume `self` and return the underlying secret, e.g. to move it into
30    /// another owned value without an extra clone.
31    ///
32    /// Callers must not log, store, or otherwise persist the returned value.
33    #[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/// `serialize_with` helper for an `Option<SecretString>` field that must
50/// round-trip (e.g. persisted config).
51///
52/// `SecretString` deliberately has no `Serialize` impl of its own — that would
53/// make *every* field serialize its secret and defeat the type. This opt-in
54/// helper exposes the value for one explicitly-annotated field only:
55///
56/// ```ignore
57/// #[serde(default, serialize_with = "toolkit_utils::secret_string::serialize_option_exposed")]
58/// pub password: Option<SecretString>,
59/// ```
60///
61/// `Debug`/`Display` stay redacted and the field is still zeroized on drop;
62/// deserialization uses `SecretString`'s own `Deserialize` (no annotation needed).
63///
64/// # Security
65///
66/// Annotating a field with this helper re-enables plaintext serialization for
67/// that field — only use it for config fields that must round-trip on disk.
68///
69/// # Errors
70///
71/// Returns the underlying `serde::Serializer`'s error if it fails to write
72/// the value.
73#[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}