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 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}