Skip to main content

nautilus_core/string/
secret.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Secret string ownership, redaction, and masking.
17
18use std::fmt::Debug;
19
20use serde::{Deserialize, Serialize};
21use zeroize::{Zeroize, ZeroizeOnDrop};
22
23/// Placeholder used in `Debug` impls to redact secret fields.
24pub const REDACTED: &str = "<redacted>";
25
26/// An owned string that zeroizes its allocation on drop and redacts its debug output.
27///
28/// Serialization intentionally emits the underlying value for wire-format compatibility. Do not
29/// serialize this type into logs or other outputs where secrets must remain redacted.
30///
31/// Equality uses ordinary string comparison and is not constant-time. Do not use it to verify an
32/// attacker-controlled secret.
33#[derive(Clone, Default, Eq, PartialEq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
34#[serde(transparent)]
35pub struct SecretString(String);
36
37impl SecretString {
38    /// Exposes the secret value as a string slice.
39    #[must_use]
40    pub fn expose_secret(&self) -> &str {
41        self.0.as_str()
42    }
43
44    /// Consumes this value and returns the secret string.
45    #[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/// Replaces a present value with [`REDACTED`] while preserving absence.
70///
71/// This is intended for optional secret fields in `Debug` implementations.
72#[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
80/// Zeroizes every owned string contained in a JSON value.
81pub 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/// Masks an API key by showing only the first and last 4 characters.
91///
92/// For keys 8 characters or shorter, returns asterisks only.
93///
94/// # Examples
95///
96/// ```
97/// use nautilus_core::string::secret::mask_api_key;
98///
99/// assert_eq!(mask_api_key("abcdefghijklmnop"), "abcd...mnop");
100/// assert_eq!(mask_api_key("short"), "*****");
101/// ```
102#[must_use]
103pub fn mask_api_key(key: &str) -> String {
104    // Work with Unicode scalars to avoid panicking on multibyte characters.
105    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}