nautilus_core/string/
secret.rs1use std::fmt::Debug;
19
20use serde::{Deserialize, Serialize};
21use zeroize::{Zeroize, ZeroizeOnDrop};
22
23pub const REDACTED: &str = "<redacted>";
25
26#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
34#[serde(transparent)]
35pub struct SecretString(String);
36
37impl SecretString {
38 #[must_use]
40 pub fn expose_secret(&self) -> &str {
41 self.0.as_str()
42 }
43
44 #[must_use]
46 pub fn into_inner(mut self) -> String {
47 std::mem::take(&mut self.0)
48 }
49}
50
51impl From<String> for SecretString {
52 fn from(value: String) -> Self {
53 Self(value)
54 }
55}
56
57impl Debug for SecretString {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.write_str(REDACTED)
60 }
61}
62
63impl From<&str> for SecretString {
64 fn from(value: &str) -> Self {
65 Self(value.to_owned())
66 }
67}
68
69#[must_use]
73pub const fn redact_option<T: ?Sized>(value: Option<&T>) -> Option<&'static str> {
74 match value {
75 Some(_) => Some(REDACTED),
76 None => None,
77 }
78}
79
80pub fn zeroize_json_value(value: &mut serde_json::Value) {
82 match value {
83 serde_json::Value::String(value) => value.zeroize(),
84 serde_json::Value::Array(values) => values.iter_mut().for_each(zeroize_json_value),
85 serde_json::Value::Object(values) => values.values_mut().for_each(zeroize_json_value),
86 serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
87 }
88}
89
90#[must_use]
103pub fn mask_api_key(key: &str) -> String {
104 let chars: Vec<char> = key.chars().collect();
106 let len = chars.len();
107
108 if len <= 8 {
109 return "*".repeat(len);
110 }
111
112 let first: String = chars[..4].iter().collect();
113 let last: String = chars[len - 4..].iter().collect();
114
115 format!("{first}...{last}")
116}
117
118#[cfg(test)]
119mod tests {
120 use rstest::rstest;
121 use zeroize::ZeroizeOnDrop;
122
123 use super::*;
124
125 fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
126
127 #[rstest]
128 fn test_secret_string_redacts_debug() {
129 let value = SecretString::from("session-secret");
130
131 assert_eq!(value.expose_secret(), "session-secret");
132 assert_eq!(format!("{value:?}"), REDACTED);
133 assert_zeroize_on_drop::<SecretString>();
134 }
135
136 #[rstest]
137 fn test_secret_string_serde_is_transparent() {
138 let value = SecretString::from("session-secret");
139
140 let serialized = serde_json::to_string(&value).unwrap();
141 let deserialized: SecretString = serde_json::from_str(&serialized).unwrap();
142
143 assert_eq!(serialized, r#""session-secret""#);
144 assert_eq!(deserialized.expose_secret(), "session-secret");
145 }
146
147 #[rstest]
148 fn test_zeroize_json_value_clears_nested_strings() {
149 let mut value = serde_json::json!({
150 "secret": "top-level",
151 "nested": ["array-value", {"secret": "nested-value"}],
152 "number": 42,
153 });
154
155 zeroize_json_value(&mut value);
156
157 assert_eq!(value["secret"], "");
158 assert_eq!(value["nested"][0], "");
159 assert_eq!(value["nested"][1]["secret"], "");
160 assert_eq!(value["number"], 42);
161 }
162
163 #[rstest]
164 #[case("", "")]
165 #[case("a", "*")]
166 #[case("abc", "***")]
167 #[case("abcdefgh", "********")]
168 #[case("abcdefghi", "abcd...fghi")]
169 #[case("abcdefghijklmnop", "abcd...mnop")]
170 #[case("VeryLongAPIKey123456789", "Very...6789")]
171 fn test_mask_api_key(#[case] input: &str, #[case] expected: &str) {
172 assert_eq!(mask_api_key(input), expected);
173 }
174
175 #[rstest]
176 fn test_redact_option_present() {
177 assert_eq!(redact_option(Some("secret")), Some(REDACTED));
178 }
179
180 #[rstest]
181 fn test_redact_option_absent() {
182 assert_eq!(redact_option(None::<&str>), None);
183 }
184}