Skip to main content

clawft_types/
secret.rs

1//! Secret string wrapper that prevents accidental exposure.
2//!
3//! [`SecretString`] wraps sensitive values (API keys, passwords, tokens) and
4//! ensures they never appear in logs, Debug output, or serialized JSON.
5
6use std::fmt;
7
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10/// A string value that should not appear in logs, Debug output, or serialized JSON.
11///
12/// - `Debug` prints `[REDACTED]` (or `""` if empty)
13/// - `Serialize` emits an empty string (never the actual value)
14/// - `Deserialize` accepts a plain string (backward compatible with old configs)
15/// - `Display` prints `[REDACTED]` (or empty if the value is empty)
16/// - [`expose()`](SecretString::expose) returns the inner value for actual use
17#[derive(Clone, Default)]
18pub struct SecretString(String);
19
20impl SecretString {
21    /// Create a new `SecretString` wrapping the given value.
22    pub fn new(value: impl Into<String>) -> Self {
23        Self(value.into())
24    }
25
26    /// Get the actual secret value. Use sparingly and only where needed
27    /// (e.g., HTTP Authorization headers, API calls).
28    pub fn expose(&self) -> &str {
29        &self.0
30    }
31
32    /// Returns `true` if the wrapped value is empty.
33    pub fn is_empty(&self) -> bool {
34        self.0.is_empty()
35    }
36}
37
38impl fmt::Debug for SecretString {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        if self.0.is_empty() {
41            write!(f, "\"\"")
42        } else {
43            write!(f, "\"[REDACTED]\"")
44        }
45    }
46}
47
48impl fmt::Display for SecretString {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        if self.0.is_empty() {
51            write!(f, "")
52        } else {
53            write!(f, "[REDACTED]")
54        }
55    }
56}
57
58impl Serialize for SecretString {
59    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
60        // Never serialize the actual secret value.
61        serializer.serialize_str("")
62    }
63}
64
65impl<'de> Deserialize<'de> for SecretString {
66    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
67        let s = String::deserialize(deserializer)?;
68        Ok(SecretString(s))
69    }
70}
71
72impl From<String> for SecretString {
73    fn from(s: String) -> Self {
74        SecretString(s)
75    }
76}
77
78impl From<&str> for SecretString {
79    fn from(s: &str) -> Self {
80        SecretString(s.to_string())
81    }
82}
83
84impl PartialEq for SecretString {
85    fn eq(&self, other: &Self) -> bool {
86        self.0 == other.0
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn debug_redacts_non_empty() {
96        let s = SecretString::new("my-secret-key");
97        assert_eq!(format!("{:?}", s), "\"[REDACTED]\"");
98    }
99
100    #[test]
101    fn debug_shows_empty_for_empty() {
102        let s = SecretString::default();
103        assert_eq!(format!("{:?}", s), "\"\"");
104    }
105
106    #[test]
107    fn display_redacts_non_empty() {
108        let s = SecretString::new("secret");
109        assert_eq!(format!("{}", s), "[REDACTED]");
110    }
111
112    #[test]
113    fn display_empty_for_empty() {
114        let s = SecretString::default();
115        assert_eq!(format!("{}", s), "");
116    }
117
118    #[test]
119    fn expose_returns_actual_value() {
120        let s = SecretString::new("actual-secret");
121        assert_eq!(s.expose(), "actual-secret");
122    }
123
124    #[test]
125    fn is_empty_works() {
126        assert!(SecretString::default().is_empty());
127        assert!(!SecretString::new("x").is_empty());
128    }
129
130    #[test]
131    fn serialize_emits_empty_string() {
132        let s = SecretString::new("my-api-key");
133        let json = serde_json::to_string(&s).unwrap();
134        assert_eq!(json, "\"\"");
135        assert!(!json.contains("my-api-key"));
136    }
137
138    #[test]
139    fn deserialize_accepts_plain_string() {
140        let s: SecretString = serde_json::from_str("\"my-api-key\"").unwrap();
141        assert_eq!(s.expose(), "my-api-key");
142    }
143
144    #[test]
145    fn from_string() {
146        let s: SecretString = "test".into();
147        assert_eq!(s.expose(), "test");
148    }
149
150    #[test]
151    fn from_owned_string() {
152        let s: SecretString = String::from("test").into();
153        assert_eq!(s.expose(), "test");
154    }
155
156    #[test]
157    fn equality() {
158        let a = SecretString::new("same");
159        let b = SecretString::new("same");
160        let c = SecretString::new("different");
161        assert_eq!(a, b);
162        assert_ne!(a, c);
163    }
164}