securitydept_utils/
secret.rs1use std::fmt;
2
3use redact::Secret;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5
6pub const REDACTED_SECRET: &str = "[REDACTED]";
7
8#[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}