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 len = key.chars().count();
106
107 if len <= 8 {
108 return "*".repeat(len);
109 }
110
111 let first_end: usize = key.chars().take(4).map(char::len_utf8).sum();
112 let last_len: usize = key.chars().rev().take(4).map(char::len_utf8).sum();
113 let mut masked = String::with_capacity(first_end + 3 + last_len);
114 masked.push_str(&key[..first_end]);
115 masked.push_str("...");
116 masked.push_str(&key[key.len() - last_len..]);
117 masked
118}
119
120#[cfg(test)]
121mod tests {
122 use proptest::prelude::*;
123 use rstest::rstest;
124 use zeroize::ZeroizeOnDrop;
125
126 use super::*;
127
128 fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
129
130 #[rstest]
131 fn test_secret_string_redacts_debug() {
132 let value = SecretString::from("session-secret");
133
134 assert_eq!(value.expose_secret(), "session-secret");
135 assert_eq!(format!("{value:?}"), REDACTED);
136 assert_zeroize_on_drop::<SecretString>();
137 }
138
139 #[rstest]
140 fn test_secret_string_serde_is_transparent() {
141 let value = SecretString::from("session-secret");
142
143 let serialized = serde_json::to_string(&value).unwrap();
144 let deserialized: SecretString = serde_json::from_str(&serialized).unwrap();
145
146 assert_eq!(serialized, r#""session-secret""#);
147 assert_eq!(deserialized.expose_secret(), "session-secret");
148 }
149
150 #[rstest]
151 fn test_zeroize_json_value_clears_nested_strings() {
152 let mut value = serde_json::json!({
153 "secret": "top-level",
154 "nested": ["array-value", {"secret": "nested-value"}],
155 "number": 42,
156 });
157
158 zeroize_json_value(&mut value);
159
160 assert_eq!(value["secret"], "");
161 assert_eq!(value["nested"][0], "");
162 assert_eq!(value["nested"][1]["secret"], "");
163 assert_eq!(value["number"], 42);
164 }
165
166 #[rstest]
167 #[case("", "")]
168 #[case("a", "*")]
169 #[case("abc", "***")]
170 #[case("abcdefgh", "********")]
171 #[case("abcdefghi", "abcd...fghi")]
172 #[case("abcdefghijklmnop", "abcd...mnop")]
173 #[case("VeryLongAPIKey123456789", "Very...6789")]
174 fn test_mask_api_key(#[case] input: &str, #[case] expected: &str) {
175 assert_eq!(mask_api_key(input), expected);
176 }
177
178 #[rstest]
179 fn test_redact_option_present() {
180 assert_eq!(redact_option(Some("secret")), Some(REDACTED));
181 }
182
183 #[rstest]
184 fn test_redact_option_absent() {
185 assert_eq!(redact_option(None::<&str>), None);
186 }
187
188 proptest! {
189 #[rstest]
190 fn prop_mask_preserves_unicode_scalars(key in "\\PC{0,128}") {
191 let chars: Vec<char> = key.chars().collect();
192 let expected = if chars.len() <= 8 {
193 "*".repeat(chars.len())
194 } else {
195 let first: String = chars[..4].iter().collect();
196 let last: String = chars[chars.len() - 4..].iter().collect();
197 format!("{first}...{last}")
198 };
199 prop_assert_eq!(mask_api_key(&key), expected);
200 }
201 }
202}