awaken_contract/
secret.rs1use std::borrow::Cow;
15use std::fmt;
16
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use secrecy::{ExposeSecret, SecretBox};
19use serde::{Deserialize, Deserializer, Serialize, Serializer};
20
21pub struct RedactedString(SecretBox<String>);
24
25impl RedactedString {
26 pub fn new(value: impl Into<String>) -> Self {
28 Self(SecretBox::new(Box::new(value.into())))
29 }
30
31 pub fn expose_secret(&self) -> &str {
37 self.0.expose_secret().as_str()
38 }
39
40 pub fn is_empty(&self) -> bool {
42 self.expose_secret().is_empty()
43 }
44
45 pub fn preview(&self) -> String {
54 let chars: Vec<char> = self.expose_secret().chars().collect();
55 if chars.len() < 12 {
56 return "***".into();
57 }
58 let head: String = chars.iter().take(4).collect();
59 let tail: String = chars.iter().skip(chars.len() - 4).collect();
60 format!("{head}***{tail}")
61 }
62}
63
64impl Clone for RedactedString {
65 fn clone(&self) -> Self {
66 Self::new(self.expose_secret().to_owned())
67 }
68}
69
70impl PartialEq for RedactedString {
71 fn eq(&self, other: &Self) -> bool {
72 self.expose_secret() == other.expose_secret()
73 }
74}
75
76impl Eq for RedactedString {}
77
78impl fmt::Debug for RedactedString {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.write_str("RedactedString(***)")
81 }
82}
83
84impl fmt::Display for RedactedString {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str("***")
87 }
88}
89
90impl From<String> for RedactedString {
91 fn from(value: String) -> Self {
92 Self::new(value)
93 }
94}
95
96impl From<&str> for RedactedString {
97 fn from(value: &str) -> Self {
98 Self::new(value)
99 }
100}
101
102impl Serialize for RedactedString {
103 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
104 serializer.serialize_str(self.expose_secret())
105 }
106}
107
108impl<'de> Deserialize<'de> for RedactedString {
109 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
110 let value = String::deserialize(deserializer)?;
111 Ok(Self::new(value))
112 }
113}
114
115impl JsonSchema for RedactedString {
116 fn schema_name() -> Cow<'static, str> {
117 Cow::Borrowed("String")
118 }
119
120 fn json_schema(generator: &mut SchemaGenerator) -> Schema {
121 String::json_schema(generator)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn debug_redacts() {
131 let s = RedactedString::new("sk-secret");
132 assert_eq!(format!("{s:?}"), "RedactedString(***)");
133 }
134
135 #[test]
136 fn display_redacts() {
137 let s = RedactedString::new("sk-secret");
138 assert_eq!(format!("{s}"), "***");
139 }
140
141 #[test]
142 fn debug_in_option_redacts() {
143 let s = Some(RedactedString::new("sk-secret"));
144 let formatted = format!("{s:?}");
145 assert!(!formatted.contains("sk-secret"), "leaked: {formatted}");
146 }
147
148 #[test]
149 fn clone_preserves_value() {
150 let original = RedactedString::new("sk-secret");
151 let copied = original.clone();
152 assert_eq!(copied.expose_secret(), "sk-secret");
153 assert_eq!(original.expose_secret(), "sk-secret");
154 }
155
156 #[test]
157 fn serde_roundtrip_preserves_value() {
158 let s = RedactedString::new("sk-secret");
159 let encoded = serde_json::to_string(&s).unwrap();
160 assert_eq!(encoded, "\"sk-secret\"");
161 let decoded: RedactedString = serde_json::from_str(&encoded).unwrap();
162 assert_eq!(decoded.expose_secret(), "sk-secret");
163 }
164
165 #[test]
166 fn json_schema_is_plain_string() {
167 let schema = schemars::schema_for!(RedactedString);
168 let value = serde_json::to_value(&schema).unwrap();
169 assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("string"));
170 }
171
172 #[test]
173 fn preview_keeps_first_and_last_four_chars() {
174 let s = RedactedString::new("sk-abcd1234567890wxyz");
175 assert_eq!(s.preview(), "sk-a***wxyz");
176 }
177
178 #[test]
179 fn preview_masks_short_values_completely() {
180 for short in ["", "abc", "sk-12345", "12345678901"] {
181 let s = RedactedString::new(short);
182 assert_eq!(
183 s.preview(),
184 "***",
185 "values shorter than 12 chars must render as `***`, got input {short:?}"
186 );
187 }
188 }
189
190 #[test]
191 fn preview_does_not_panic_on_multibyte_utf8() {
192 let s = RedactedString::new("αβγδ-中文-emoji-🔑🔑🔑🔑");
193 let preview = s.preview();
194 assert!(preview.contains("***"));
195 assert!(!preview.contains("中文"));
196 }
197}