Skip to main content

securitydept_utils/
secret.rs

1use std::fmt;
2
3use redact::Secret;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6pub const REDACTED_SECRET: &str = "[REDACTED]";
7
8/// Secret-bearing string wrapper that redacts debug/serialization output.
9#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
10#[cfg_attr(
11    feature = "config-schema",
12    schemars(with = "String", extend("format" = "password", "writeOnly" = true))
13)]
14#[derive(Clone, Default, Eq, PartialEq, Hash)]
15pub struct SecretString(Secret<String>);
16
17impl SecretString {
18    pub fn new(value: impl Into<String>) -> Self {
19        Self(Secret::new(value.into()))
20    }
21
22    pub fn expose_secret(&self) -> &str {
23        self.0.expose_secret().as_str()
24    }
25
26    pub fn into_exposed_secret(self) -> String {
27        self.0.expose_secret().clone()
28    }
29}
30
31impl fmt::Debug for SecretString {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        formatter.write_str("SecretString(")?;
34        formatter.write_str(REDACTED_SECRET)?;
35        formatter.write_str(")")
36    }
37}
38
39impl From<String> for SecretString {
40    fn from(value: String) -> Self {
41        Self::new(value)
42    }
43}
44
45impl From<&str> for SecretString {
46    fn from(value: &str) -> Self {
47        Self::new(value)
48    }
49}
50
51impl Serialize for SecretString {
52    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
53    where
54        S: Serializer,
55    {
56        serializer.serialize_str(REDACTED_SECRET)
57    }
58}
59
60impl<'de> Deserialize<'de> for SecretString {
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        String::deserialize(deserializer).map(Self::new)
66    }
67}
68
69pub fn deserialize_optional_secret_string<'de, D>(
70    deserializer: D,
71) -> Result<Option<SecretString>, D::Error>
72where
73    D: Deserializer<'de>,
74{
75    Ok(Option::<String>::deserialize(deserializer)?
76        .filter(|value| !value.is_empty())
77        .map(SecretString::new))
78}
79
80pub fn serialize_exposed_secret_string<S>(
81    value: &SecretString,
82    serializer: S,
83) -> Result<S::Ok, S::Error>
84where
85    S: Serializer,
86{
87    serializer.serialize_str(value.expose_secret())
88}
89
90pub fn serialize_exposed_optional_secret_string<S>(
91    value: &Option<SecretString>,
92    serializer: S,
93) -> Result<S::Ok, S::Error>
94where
95    S: Serializer,
96{
97    match value {
98        Some(secret) => serializer.serialize_some(secret.expose_secret()),
99        None => serializer.serialize_none(),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use serde::{Deserialize, Serialize};
106
107    use super::{
108        REDACTED_SECRET, SecretString, deserialize_optional_secret_string,
109        serialize_exposed_secret_string,
110    };
111
112    #[test]
113    fn deserialize_from_string() {
114        let secret: SecretString = serde_json::from_str("\"hunter2\"")
115            .expect("secret string should deserialize from a plain string");
116
117        assert_eq!(secret.expose_secret(), "hunter2");
118    }
119
120    #[test]
121    fn debug_is_redacted() {
122        let debug = format!("{:?}", SecretString::new("hunter2"));
123
124        assert!(debug.contains(REDACTED_SECRET));
125        assert!(!debug.contains("hunter2"));
126    }
127
128    #[test]
129    fn serialize_is_redacted() {
130        let encoded = serde_json::to_string(&SecretString::new("hunter2"))
131            .expect("secret string should serialize");
132
133        assert_eq!(encoded, format!("\"{REDACTED_SECRET}\""));
134    }
135
136    #[test]
137    fn explicit_expose_secret_returns_raw_value() {
138        let secret = SecretString::new("hunter2");
139
140        assert_eq!(secret.expose_secret(), "hunter2");
141        assert_eq!(secret.into_exposed_secret(), "hunter2".to_string());
142    }
143
144    #[test]
145    fn optional_helper_preserves_empty_string_as_none() {
146        #[derive(Deserialize)]
147        struct Wrapper {
148            #[serde(default, deserialize_with = "deserialize_optional_secret_string")]
149            secret: Option<SecretString>,
150        }
151
152        let wrapper: Wrapper =
153            serde_json::from_str(r#"{"secret":""}"#).expect("wrapper should deserialize");
154
155        assert!(wrapper.secret.is_none());
156    }
157
158    #[test]
159    fn raw_serialize_helper_is_explicit() {
160        #[derive(Serialize)]
161        struct Wrapper {
162            #[serde(serialize_with = "serialize_exposed_secret_string")]
163            secret: SecretString,
164        }
165
166        let encoded = serde_json::to_string(&Wrapper {
167            secret: SecretString::new("hunter2"),
168        })
169        .expect("wrapper should serialize");
170
171        assert_eq!(encoded, r#"{"secret":"hunter2"}"#);
172    }
173
174    #[cfg(feature = "config-schema")]
175    #[test]
176    fn json_schema_uses_string_password_hint() {
177        let rendered = serde_json::to_string(&schemars::schema_for!(SecretString))
178            .expect("schema should serialize");
179
180        assert!(rendered.contains("\"type\":\"string\""));
181        assert!(rendered.contains("\"format\":\"password\""));
182        assert!(rendered.contains("\"writeOnly\":true"));
183    }
184}